Compare commits

..

428 Commits

Author SHA1 Message Date
r-zakarya 5722347e75 docs: le depot a un remote, contrairement a ce qui etait ecrit
origin pointe sur le Gitea auto-heberge. CLAUDE.md affirmait "pas de
remote configure (dev local)", ce qui a cesse d'etre vrai sans que le
document suive — 427 commits d'avance au moment du constat.

Rien n'est pousse automatiquement, et c'est note : lire "il y a un
remote" ne doit pas se comprendre comme "c'est sauvegarde ailleurs".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:30:32 +01:00
r-zakarya 23568049da feat(android): saisir le secret 2FA des la creation d'une entree
Le formulaire New entry n'avait pas le champ et AddEntry ecrivait
totp_secret = '' en dur : il fallait enregistrer l'entree puis la rouvrir
pour poser un second facteur, alors que l'editeur portait deja le champ.

Meme intitule que l'editeur ("2FA secret"), masque pour une note comme
site et identifiant, et achemine par &o= — t, s, u, p, k, d etaient
pris.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:18:39 +01:00
r-zakarya 370d35f69a docs(licence): l'APK reutilisera la licence desktop, pas la sienne
Differe volontairement : l'APK ne part chez personne aujourd'hui. Le
jour venu, l'instantane publie par le poste portera la licence et le
telephone exigera d'y en trouver une — il le lit deja, donc aucun
nouveau format, aucune empreinte Android, aucune emission de plus.

Les trois raisons de ne pas refaire un second dispositif sont notees
pour ne pas les re-deriver : Java attend du DER la ou CNG accepte r||s
brut, ANDROID_ID est remis a zero par une reinitialisation d'usine (donc
des clients legitimes refuses), et un APK se repaquette en dix minutes.

Et l'APK n'aura PAS a verifier la signature, seulement la presence :
payer le DER et le JNI pour durcir un controle que le repaquetage retire
serait le mauvais echange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:13:12 +01:00
r-zakarya 67c13332d0 docs(licence): campagne d'essai passee, et l'essai qui manquait
A, B, C, D, E2, E3 passes, rien a corriger. E1 (autre compte Windows)
reste non joue, faute d'un second compte : note comme tel plutot que
presente comme verifie.

E3 ajoute au plan pendant la campagne — remplacer l'exe d'une
installation existante par une nouvelle version. C'est le cas qu'on vit
reellement a chaque livraison, et une desactivation a la mise a jour ne
se verrait qu'au premier client mecontent.

Deux observations gardees : C3 a ete plus probant que prevu (le port
avait change entre les deux lancements, donc l'origine aussi), et un
port ephemere sans ligne de repli dans le journal n'est pas un defaut
mais un config.txt absent ou port=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:49:31 +01:00
r-zakarya 253e81252c docs(licence): scenario d'essai, et le bouton qui le rend jouable
LICENCE-TESTS.md : cinq series. B eprouve ce qui doit tomber (chaine
quelconque, licence tronquee, charge utile trafiquee, autre poste, autre
paire de cles), C le risque symetrique et plus couteux — refuser un
client qui a paye (espaces colles, retour a la ligne, redemarrage
machine). D est dediee a la stabilite de l'empreinte, le defaut deja
rencontre. E delimite la portee de l'activation.

Le plan citait cmd://licence/clear sans moyen de l'appeler : un bouton
"Deactivate (debug)" au bas de Settings -> Licence, revele seulement
quand config.txt existe. Il donne au passage son unique appelant a
ClearLicence, qui etait du code mort.

Le document dit aussi ce qu'aucun essai ne prouve : rapiecer l'exe reste
possible, il n'y a pas de revocation, et le brouillage n'est pas du
chiffrement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 18:06:45 +01:00
r-zakarya fc945ac6ab fix(licence): nouvelle paire, et la privee ne nait plus dans le depot
La premiere privee a ete supprimee sans copie : plus aucune licence
nouvelle n'etait emettable. Regeneration + reemission, indolore
aujourd'hui parce qu'une seule licence circulait.

licence.ps1 ecrit desormais la cle dans %USERPROFILE%\Documents, hors du
depot. Elle vivait dans tools\, ou elle n'attendait qu'un menage : le
.gitignore la protegeait du depot, rien ne la protegeait de son
proprietaire.

Verifie : la licence de la nouvelle paire passe, celle de la paire
perdue tombe — la regeneration a bien remplace, pas ajoute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:50:14 +01:00
r-zakarya 427d294b22 feat(assets): brouiller les blocs embarques dans l'exe
Le frontend etait lisible dans l'onglet Image de Process Explorer, avec
les commentaires. BuildAssets.ps1 brouille chaque fichier avant brcc32,
PM.EmbeddedAssets le debrouille au moment de servir.

C'est de l'opacite, pas du secret : la cle est dans l'exe. Ca supprime
strings et l'onglet Image ; la memoire, un debogueur et la reponse HTTP
rendent toujours le texte. D'ou XOR plutot qu'AES — a securite egale
(nulle contre qui a l'exe), dix lignes, et aucune force affichee qu'on
n'a pas.

Une magie PMA1 prefixe le clair : sans elle, deux constantes qui derivent
serviraient du binaire au navigateur, ecran blanc sans trace.

Verifie en comparant les flux des deux cotes bloc par bloc, puis par
aller-retour sur app.js, index.html et style.css. Le frontend ne se
trouve plus dans le binaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:17:15 +01:00
r-zakarya 8f0bbb580f feat(licence): Settings affiche le porteur de la licence
Porteur, courriel, date d'emission et Installation ID, lus dans la charge
utile deja verifiee. Rempli une fois au demarrage sur les deux chemins
(deja licencie, activation reussie) : rien n'y change pendant une
session. Section masquee hors navigateur hote.

Les champs sont echappes avant interpolation dans ExecuteJavaScript bien
qu'ils viennent d'une charge utile signee — signee ne veut pas dire
inoffensive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:19:24 +01:00
r-zakarya 61ec5ff7a1 fix(licence): l'empreinte ne depend plus de l'ordre de demarrage
BoardUUID s'appuyait sur un COM initialise par quelqu'un d'autre. Appele
apres WebView2 il lisait le vrai UUID de carte mere, appele avant il
echouait et retombait sur MachineGuid : deux empreintes pour une seule
machine, et une licence liee a l'une que l'autre refuse. Il suffisait que
licence/status parte avant l'init du navigateur pour verrouiller
l'utilisateur hors de son coffre.

CoInitializeEx est desormais appele sur place. Pas de CoUninitialize
symetrique : essaye, il rend une violation d'acces — les mandataires WMI
et le cache de type-info de ComObj se liberent plus tard, dans un
appartement qu'on vient de fermer.

Verifie avec un harnais qui n'initialise PAS COM : il rend maintenant la
meme empreinte que l'application.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:00:50 +01:00
r-zakarya 988694b20d feat(licence): refuser les licences non liees a un poste
Une cle flottante etait sortie. Un schema hors ligne n'a pas de
revocation — rien ne peut aller dire a un exe installe qu'une cle ne vaut
plus — donc la seule prise est de cesser d'accepter toute la classe.

MachineMatches exige desormais une empreinte, et -Floating disparait du
script : il ne peut plus fabriquer une cle que l'exe rejetterait.

Effet de bord assume : plus de licence de site, et un poste sans
empreinte exploitable n'est plus licenciable du tout — mieux qu'une
empreinte degeneree qui ouvrirait partout.

Verifie : liee a ce poste acceptee, liee a un autre refusee, les deux
flottantes deja emises refusees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:38:07 +01:00
r-zakarya 405ac7fac4 feat(licence): lier la licence au poste (carte mere + disque)
C'etait la decision prise, et je l'avais mal lue : "pour le moment 1"
repondait a la question 1, pas "une seule licence".

PM.MachineId melange l'UUID de la carte mere (WMI) et la serie du volume
systeme, puis n'en publie que 16 hexa : l'empreinte se recopie dans un
courriel, l'UUID brut n'a pas a circuler. Un UUID nul ou tout a FF est
rejete — VM et images clonees l'annoncent, et deux postes tomberaient
alors sur la meme empreinte.

Le champ m est compare APRES la signature. m absent = licence flottante,
acceptee : seule la cle privee peut en emettre, et le champ ne peut pas
etre retire sans casser la signature. Cote script -Machine est
obligatoire sauf -Floating explicite.

L'ecran d'activation affiche l'Installation ID : une licence liee ne peut
plus etre emise avant l'installation.

Verifie sur ce poste : liee a lui acceptee, liee a un autre refusee,
flottante acceptee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:16:35 +01:00
r-zakarya 2cb7d29450 fix(licence): une cle refusee s'affiche en rouge
Le cyan de .auth-hint dit "voici l'etat des choses" (session expiree,
coffre verrouille). Un refus n'est pas un etat, c'est un echec : il doit
se distinguer a la couleur avant d'etre lu. Variante .is-error posee sur
le refus, retiree a chaque nouvel essai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:09:04 +01:00
r-zakarya d85f01ede2 feat(licence): la cle publique de l'editeur est posee
L'ecran d'activation devient franchissable. Verifie en executant l'unite
contre la vraie cle : licence emise acceptee et charge utile lue, licence
signee par une AUTRE paire refusee — c'est celle-la qui prouve que la cle
tient reellement la porte — charabia et chaine vide refuses.

La cle privee reste hors du depot (gitignoree) et doit etre deplacee dans
un coffre : qui la detient fabrique des licences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 15:00:09 +01:00
r-zakarya cd6e70449d feat(licence): ecran d'activation au demarrage
ensureLicence() en tete d'init(), avant tout le reste : un ecran
d'authentification qui s'affiche puis se fait recouvrir donnerait
l'impression que le coffre etait accessible.

L'ecran reprend les classes de l'ecran d'authentification — zero style
nouveau hormis un z-index. Le statut est demande deux fois avant de le
montrer : un demarrage a froid lent manquerait la fenetre de reponse, et
le silence se lirait comme un refus.

Une cle refusee ne dit pas pourquoi, et le journal note le verdict sans
jamais la cle.

ATTENTION : tant que LicencePublicKeyBlob est a zero, l'ecran est
infranchissable. Poser la cle publique avant de deployer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:58:19 +01:00
r-zakarya 960d2b388a feat(licence): verification hors ligne d'une licence signee
PM.Licence verifie une chaine charge-utile.signature contre la cle
publique de l'editeur, via BCryptVerifySignature. Aucun ASN.1 : la
signature du script est deja en r||s brut et les coordonnees se rangent
telles quelles dans un BCRYPT_ECCPUBLIC_BLOB.

La cle publique est livree A ZERO — tant que la sortie de licence.ps1
-Init n'y est pas collee, aucune licence n'est valide. C'est le defaut
sur : un blob par defaut qui accepterait quelque chose serait pire.

IsLicensed REVERIFIE la licence rangee plutot que de lire un drapeau.
La signature est verifiee AVANT que la charge utile soit analysee.

SelfTestLicence a l'initialization rejoue un vecteur fige signe par une
paire de test : valable accepte, un octet retourne refuse. Verifie en
executant l'unite, pas seulement en la compilant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:44:55 +01:00
r-zakarya 2b9f3ca37d feat(licence): fabrique de licences signees ECDSA P-256
Script a lancer chez soi, jamais livre. -Init cree la paire et imprime
la constante Delphi du BCRYPT_ECCPUBLIC_BLOB ; -For emet une licence
payload.signature en base64url.

.NET sur Windows EST CNG : la signature produite (r||s brut, 64 octets)
est celle qu'attend BCryptVerifySignature, et les coordonnees exportees
se rangent telles quelles dans le blob. Aucun ASN.1 des deux cotes.

Verifie en rejouant l'import du blob par CngKey.Import : licence valide
acceptee, un octet retourne dans la charge utile la fait tomber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:36:09 +01:00
r-zakarya fc03c90c95 fix(settings): le toggle autofill survit au changement de port
localStorage meurt avec l'origine a chaque lancement, donc un autofill
desactive revenait sur ON. Miroite en pref DPAPI comme unlockMode et
rememberedUsername ; la copie DPAPI fait autorite, relue dans enterApp
juste avant la poussee des combos vers Delphi.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 13:04:42 +01:00
r-zakarya bf9b62a541 feat(server): un port occupe ne tue plus le demarrage
Le lien n'etait garde par rien : un port pris levait, et l'application
s'ouvrait sur une boite d'erreur. Trois essais desormais - le premier
honore le port de config.txt, que l'utilisateur a peut-etre choisi
expres, les suivants prennent un ephemere.

SE LIER EST LE TEST. Sonder le port avant serait plus faible - un service
qui ecoute sans repondre passerait inapercu - et sujet a une course : le
port peut etre pris entre le sondage et le lien. Chaque deplacement est
journalise, un serveur qui change de port en silence etant pire qu'un
serveur qui refuse de demarrer.

Trouve en chemin : Randomize n'est appele NULLE PART, donc le port
"aleatoire" de port=0 etait le meme a chaque lancement - l'aleatoire cense
compliquer un balayage exterieur n'existait pas. Il vient maintenant de
RandomBytes, donc de BCryptGenRandom, deja utilise pour le jeton d'acces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:45:32 +01:00
r-zakarya 77688a5af2 chore(autofill): le journal dit par quel geste on est entre
La barrette et le raccourci se rejoignent des FireAutofillRequest, donc
tout ce qui suit etait journalise sous "hotkey" - y compris un
remplissage declenche a la souris. Un diagnostic de lenteur qui ne sait
pas par ou l'on est entre cherche au mauvais endroit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:36:20 +01:00
r-zakarya da74a675be chore(autofill): mesurer ou passe le temps d'un remplissage
Un autofill lent apres une periode d'inactivite peut l'etre pour des
raisons sans rapport qui se ressemblent parfaitement de l'exterieur : le
moteur WebView2 qui se reveille, la prise du premier plan, ou l'attente
de la relache des modificateurs. Elles se corrigent a des endroits
opposes, donc on les separe au lieu de deviner laquelle accuser.

Trois marques : le temps que met la PAGE a repondre au raccourci, le
total, et le detail des deux attentes internes.

Premiere mesure : 13 ms de reponse apres 23 minutes en tray, total
635 ms. Le renderer ne dormait donc pas - l'hypothese la plus evidente
etait fausse - et 635 ms est le cout nominal de la sequence, faite de
pauses fixes (80 + 120 + 60 + 200 + 200). Le cas lent reste a attraper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 19:31:03 +01:00
r-zakarya f8b7ffe379 refactor(autofill): "Fill bar" plutot que "Lazy mode"
Le premier nomme la chose, le second decrivait l'utilisateur. Le signe
qui a tranche : l'ancien libelle avait besoin d'une parenthese pour se
faire comprendre - "Lazy mode (fill with the mouse)" - et c'etait elle
qui portait le sens.

Seul le libelle change. Les identifiants internes gardent "lazy" -
FLazyWnd, LAZY_*, et surtout les prefs lazyOn / lazyPos / lazyAlphaPct :
renommer ces cles perdrait les reglages deja poses sur la machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:03:06 +01:00
r-zakarya 5bcb969552 docs(android): la serie M est close, et raconte ses quatre obstacles
L'APK signe existe. Le chemin -Release n'avait jamais tourne et a butte
quatre fois - dossier de mise en scene absent, volume plein, cinq
doublons refuses par aapt, ressources generees jamais rangees. Toutes des
choses que seule une release partant d'un terrain vierge pouvait
rencontrer.

La verification de la section Debug est corrigee : chercher wipelocal
dans le .so ne prouve rien, la page HTML embarquee la contient dans les
deux configurations. Seule la chaine du gestionnaire Pascal tranche, et
en UTF-16LE. La garantie est "inatteignable et sans effet", pas
"absente".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:00:46 +01:00
r-zakarya fa2160104c fix(android): la release place elle-meme les ressources generees
Le Build depose a plat dans Android64\<Config>\ les ressources d'Android
- styles.xml, strings.xml, les variantes -v21/-v31, l'icone adaptative -
et c'est le Deploy qui devrait les ranger dans res\. Il le fait en DEBUG
et pas en RELEASE, alors que les proprietes Android_* du .dproj sont
communes aux deux configurations et qu'aucune entree de deploiement ne
les nomme.

Sans elles, aapt refuse le paquet sur "@style/AppTheme" introuvable : le
manifeste reference un theme que plus rien ne definit.

Le script les range donc lui-meme, avant nos propres ressources pour que
notre colors.xml garde le dernier mot. La correspondance nom-fichier vers
sous-dossier est transcrite explicitement : un fichier nouveau se
signalera au lieu d'etre range au hasard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:54:24 +01:00
r-zakarya 719522df92 fix(android): aapt refusait le paquet a cause de cinq doublons
Le deploiement de Delphi depose a la racine de res\ une copie a plat de
cinq de ses ressources - ic_launcher_*, splash_* - alors que les vraies
sont dans res\drawable\. Or aapt n'accepte que des DOSSIERS a cette
racine : il refusait le paquet entier, "invalid resource directory name",
une fois par fichier.

On les retire avant l'appel, et rien n'est perdu : ce sont des doublons,
verifies presents dans drawable.

Ne se voyait pas en debogage, ou l'arbre traine depuis longtemps. C'est
la premiere release qui l'a montre, comme le dossier de mise en scene
absent juste avant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:41:05 +01:00
r-zakarya 8d51aa54f7 fix(android): la premiere release echouait sur un dossier absent
L'arbre de mise en scene est creuse par l'etape Deploy, qui vient APRES
les trois copies qui s'y deversent. En debogage il existait deja, laisse
par un deploiement anterieur, et personne ne s'en apercevait ; pour une
configuration jamais empaquetee, la premiere copie tombait sur un chemin
absent - "Could not find a part of the path".

Les creer d'abord ne coute rien quand elles existent. C'est exactement le
defaut que TESTS.md annoncait : le chemin -Release n'avait jamais tourne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:34:39 +01:00
r-zakarya cbcdb3f593 fix(android): le temoin bleu sait enfin s'eteindre
FRemoteAhead etait remis a faux a trois endroits - publication,
publication differee, rafraichissement - et aucun ne le disait a la page.
Seul l'allumage etait pousse. Le point bleu restait donc affiche apres la
synchronisation qui venait de le rendre caduc, jusqu'au retour suivant
sur l'accueil.

Un temoin qui reste allume apres qu'on a fait ce qu'il demandait ne
demande plus rien : il decore, et on apprend a ne plus le regarder.

Le drapeau et son affichage passent maintenant par SetRemoteAhead, dans
les deux sens. Meme asymetrie, meme correction que pour le point orange.

PROBE_EVERY_TICKS revient a 40 : la bascule a 8 n'etait que pour rendre
la serie E observable en deux minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:27:40 +01:00
r-zakarya b2c677997e fix(android): un faux echec de synchronisation, et des boutons colles
Le chien de garde mesurait la duree TOTALE, alors que son message dit
"no reply from the server". Sur un coffre devenu gros - 286 Ko - la
reponse arrivait en 841 ms et les vingt secondes suivantes partaient dans
la derivation de la cle puis l'affichage : il criait donc a l'absence de
reponse alors que tout avait reussi, cache ecrit compris. Un faux echec
est pire qu'un vrai, il apprend a se mefier de ce que l'application dit.

Il est desarme des que le statut HTTP est connu, quel qu'il soit : ce qui
suit ne depend plus du reseau, et le voile est leve par le finally.

Au passage, les boutons des reglages n'avaient pas de margin-top : le
champ du code et ses deux boutons se touchaient et se lisaient comme un
seul bloc. Les piles de l'accueil ont leur gap ; ici les elements se
suivent en flux normal, donc c'est au bouton de reserver sa place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:22:45 +01:00
r-zakarya d856e51230 docs(android): la serie E demandait une chose impossible
AUTOLOCK_MINUTES = 3 verrouille apres trois minutes d'inactivite, tandis
que PROBE_EVERY_TICKS = 40 - quinze secondes le battement - demande dix
minutes de coffre OUVERT. Le compteur n'avance que tant qu'il l'est, et
LockVault sort avant de l'incrementer. "Laisser ouvert dix minutes sans y
toucher" verrouillait donc a la troisieme, et le sondage ne partait
jamais.

Ce n'est pas un defaut : le temoin sert pendant qu'on se sert du coffre,
et qui s'en sert le touche. Mais sans le dire, on cherche un point qui ne
peut pas paraitre. La serie demande maintenant de toucher toutes les deux
minutes, et offre la contre-epreuve courte - PROBE_EVERY_TICKS a 8 le
temps d'un essai.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 00:02:48 +01:00
r-zakarya 23dc0a78ac feat(sync): Sync now dans le menu Tools
Synchroniser est une ACTION, pas un reglage. Elle n'existait qu'au fond
du panneau des reglages, a cote de l'URL et du jeton : on l'y configure
une fois, on la declenche tous les jours.

Sans URL, sans "Enable sync" ou sans mot de passe de sync, l'appui ouvre
les reglages ET DESCEND jusqu'a la section - un bouton qui ne peut
qu'echouer ne vaut pas mieux que pas de bouton, et repondre "Sync not
configured" laisse chercher ou reparer. Le panneau n'a pas d'onglets,
c'est une colonne longue : le battement dit lequel des blocs on est venu
chercher.

L'etat est lu SANS les secrets - hasEncPwd dit que le mot de passe existe
sans le lire, ce que loadSyncConfig documente : les tirer les planterait
dans le renderer pour la vie du processus.

L'entree disparait sans le pont, comme la section : le frontend PHP n'a
pas de sortie reseau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:59:52 +01:00
r-zakarya fba05f1ce5 fix(import): la restauration dupliquait l'historique, comme la sync
Le meme defaut, par une autre porte : restaurer une sauvegarde par-dessus
des entrees existantes passe aussi par encryptImportEntry puis PUT, donc
re-chiffre un mot de passe inchange et fait croire au serveur qu'il a
change. La sync etait la cause de ce qui se voyait, pas la seule.

La regle est extraite en une fonction, keepCiphertextIfSamePassword,
appelee par les deux. Les re-publications partielles - tag, dossier,
migration des metadonnees - sont hors de cause : elles reutilisent deja
le chiffre stocke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:47:36 +01:00
r-zakarya 8905599214 fix(sync): la synchronisation dupliquait l'historique des mots de passe
L'instantane transporte le mot de passe EN CLAIR, et encryptImportEntry
le re-chiffre avec un IV neuf : le chiffre differe donc toujours, meme
quand le mot de passe n'a pas bouge. Or le serveur decide d'ecrire une
version dans l'historique en comparant les chiffres
(`encrypted_password <> :newenc`) - il voyait une difference la ou il n'y
en avait aucune, et chaque synchronisation qui redescendait une entree y
deposait un doublon date du jour. L'historique montrait la meme valeur
deux fois, a des jours d'ecart.

Le serveur ne peut pas trancher, il ne voit que du chiffre. Seul le
client connait les deux clairs : il compare, et rend le chiffre d'origine
quand ils sont egaux - ce que le commentaire du handler appelle deja
"JS reuses originalEncrypted bit-for-bit".

Trouve en deroulant la serie H sur le telephone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 23:33:21 +01:00
r-zakarya 4fda63ec30 feat(android): creer une entree et son dossier d'un seul geste
"+ new folder" n'existait qu'a l'edition. Creer une entree ET son dossier
demandait donc trois gestes - creer, rouvrir, changer le dossier - pour
ce que le meme controle sait faire ailleurs en un seul. L'asymetrie ne se
voyait qu'en s'en servant.

Deux cas que le chemin de l'edition ne posait pas : un champ laisse vide
et le marqueur interne du "+ new folder" retombent tous deux sur All -
sans quoi valider sans rien taper aurait envoye au coffre un nom de
dossier fait d'un caractere de controle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:54:46 +01:00
r-zakarya 363b934e8a fix(android): le temoin d'attente parait des l'accueil, sans ouvrir le coffre
FPending est relu au demarrage, mais PM.setPending n'etait pousse que par
OpenLocalNow : la page n'apprenait donc l'attente qu'en OUVRANT le
coffre. Un demarrage a froid montrait Sync sans point orange - l'ecran ou
l'on decide de synchroniser, et le seul moment ou ce drapeau relu au
demarrage sert a quelque chose. Il fallait ouvrir puis reverrouiller pour
qu'il paraisse.

Les deux temoins se reposent maintenant dans ShowUnlock, donc a chaque
retour sur l'accueil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:31:27 +01:00
r-zakarya 99af31a111 docs(android): l'ordre des series n'est pas alphabetique
La serie A guette un point orange sur un coffre ne ici, ou il ne peut pas
paraitre : CommitChange voit LLocalOnly, ecrit localement et ne publie
rien - il n'y a pas de depot tant qu'on n'a pas reconcilie. On aurait
conclu a un defaut la ou il n'y a que l'ordre.

C reconcilie, donc C passe avant A.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 22:05:29 +01:00
r-zakarya 277e710754 docs(android): TESTS.md dit d'ou l'on part
Plusieurs series agissent sur une entree existante, sans dire d'ou elle
vient. Partir d'un coffre vide les rend ininterpretables : on ne
distingue pas "la modification n'a pas voyage" de "il n'y avait rien a
faire voyager".

Etat de depart : coffre neuf, une seule entree, un seul dossier - All, et
c'est le premier enregistrement qui le fait apparaitre.

Le coffre vide reste un cas a eprouver, mais nomme comme tel : c'est lui
qui a revele que "coffre ouvert" se lisait sur le nombre d'entrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:55:05 +01:00
r-zakarya eb49a4ab50 docs(android): TESTS.md decrit l'application telle qu'elle est
La version precedente decrivait une application qui n'existe plus - deux
secrets confondus, des reglages qui n'appliquaient rien sur-le-champ, un
coffre qui s'ouvrait sans rien demander.

Trois series nouvelles : les deux secrets (C), l'ouverture qui demande
quelque chose (D), et le clavier avec le retour d'application (I). F est
reprise en entier. La remise a zero passe desormais par le bouton Debug
plutot que par adb, ce qui rend le scenario deroulable sans machine.

En tete, ce que la seance a vraiment appris : les defauts se tiennent par
la main, et le journal doit se lire AVANT de retenter - la
synchronisation le vide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:52:43 +01:00
r-zakarya 85691bb2f1 feat(android): un bouton pour jeter le coffre local, en debogage seulement
Il vit en bas des reglages, apres tout le reste, et demande deux appuis :
un effacement sans retour ne se declenche pas sur un doigt qui glisse.

Absent en release, et pas seulement cache : la commande du pont n'est pas
compilee et Delphi ne devoile la section que dans une version de
debogage. Le bouton ne peut donc pas reapparaitre par accident. Verifie -
Release compte 8060 lignes contre 8072 en debug, soit exactement ce bloc.

DropLocalVault efface le cache, le conteneur local et le drapeau
d'attente, chacun sous son propre try : un fichier absent ou verrouille
ne doit pas empecher les deux autres de partir. Les reglages du depot
restent - c'est le coffre qu'on jette, pas la facon d'aller le rechercher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:47:53 +01:00
r-zakarya d43176576f style(android): les boutons du bandeau sont ronds 2026-08-28 17:52:18 +01:00
r-zakarya cc51540283 fix(android): le clavier se replie une fois l'appui passe
Retenir le focus au mousedown fait atteindre la cible du premier coup,
mais laissait le clavier ouvert : apres "Open vault" il restait dresse
par-dessus la liste. Les deux moities vont ensemble - on retient le focus
le temps de l'appui, on le lache des que l'action est partie.

Sauf si le bouton agit sur le champ lui-meme : un oeil qui devoile, une
fleche qui deroule. Ceux-la vivent dans la meme rangee que le champ, et
replier le clavier apres eux obligerait a le rouvrir pour continuer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:47:59 +01:00
r-zakarya 0ec32a7c19 fix(android): le message ne se cache plus derriere le clavier
Le toast vit a bottom: 14px, donc sous le clavier des que celui-ci est
leve : il ne se decouvrait qu'en quittant l'ecran, quand le clavier se
repliait. Et depuis qu'un appui sur un bouton ne vole plus le focus, le
clavier RESTE ouvert - ce qui a rendu systematique un defaut jusque-la
occasionnel.

Il remonte au-dessus de la reserve que body.typing menage deja pour le
clavier : meme mesure, meme raison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:43:26 +01:00
r-zakarya 0930a866c2 fix(android): le premier appui n'est plus mange par le clavier
Un bouton touche pendant qu'un champ a le focus recevait d'abord le
blur : le clavier se repliait, la reserve de body.typing disparaissait,
la page remontait - et le bouton n'etait plus sous le doigt quand le clic
partait. Il fallait appuyer deux fois, la premiere ne servant qu'a fermer
le clavier.

Empecher le defaut du mousedown empeche le TRANSFERT DE FOCUS, pas le
clic : le champ garde le curseur, rien ne bouge, et l'appui atteint sa
cible du premier coup. Pose sur toute la page - le piege n'a aucune
raison d'etre propre aux reglages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:40:19 +01:00
r-zakarya b3f725137e fix(android): le message se voit dans les reglages, et le code s'y cache coffre ferme
Le toast n'avait pas de z-index : il se peignait DERRIERE l'ecran des
reglages, qui est a 80. "Unlock code set" partait bien, mais sous la
page, et ne se decouvrait qu'en sortant - quand il ne s'etait pas deja
eteint. Un message qui ne se voit pas la ou on l'a provoque ne sert a
rien.

La section du code de deverrouillage ne parait plus que coffre ouvert.
Poser comme retirer exigent le clair en main : coffre ferme, les deux ne
pouvaient que refuser, et proposer une action qui refuse toujours vaut
moins que ne rien proposer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:36:37 +01:00
r-zakarya d428a6fc7f feat(android): un coffre telecharge n'est plus lisible sans rien demander
Sans biometrie utilisable et sans code pose, le cache s'ouvrait sans rien
demander - or il CONTIENT le mot de passe de sync. Qui tenait le
telephone deverrouille obtenait donc le coffre ET le secret du depot.

Il est desormais scelle sous le mot de passe qui vient de le rapporter :
celui-ci est deja dans ce fichier, donc rien de neuf n'est expose, et
l'utilisateur le connait puisqu'il vient de le taper. C'est aussi la
seule forme de cache qui survive a une reinitialisation du verrouillage
d'ecran.

Pas quand l'appareil sait authentifier : VER_PIN et VER_WRAP s'excluent,
et imposer une frappe a qui a une empreinte serait un recul.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:25:16 +01:00
r-zakarya 5cc80727d6 fix(android): l'exception etait lue apres sa liberation
Register et Login mettaient leur gestionnaire d'erreur en file avec
TThread.Queue, puis y lisaient E.ClassName et E.Message. Or la file
s'execute sur le thread principal bien apres que le except a rendu la
main, et Delphi libere E en sortant du gestionnaire : c'etait une lecture
apres liberation.

Elle rendait un message vide - "Login failed: EPMACrypto - " sans la
raison - et parfois une violation d'acces qui remontait a FMX sans rien
laisser au journal. Le vrai motif de l'echec n'a donc jamais ete affiche.

On ne met plus en file que des chaines, qui survivent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:46:24 +01:00
r-zakarya 4e0223bee5 fix(android): aucune exception ne sort du pont sans laisser son nom
Le dispatch n'etait garde par rien : une faute dans un handler remontait
jusqu'a FMX, qui affichait "Access violation" avec une adresse, tandis
que le journal ne montrait que la commande recue suivie de rien. On
savait qu'une commande echouait, jamais laquelle ni pourquoi.

On attrape, on nomme, et on rend la main - une commande fautive ne doit
pas laisser l'interface voilee derriere une boite de dialogue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:39:10 +01:00
r-zakarya a41c61405c fix(android): retirer le code de deverrouillage demande de le connaitre
Le coffre ouvert ne prouvait rien : il peut l'etre depuis une heure et
rester a l'ecran. Quiconque trouvait le telephone dans cet etat levait la
protection en deux appuis, sans que rien ne le lui demande.

Le code courant voyage maintenant avec la demande, et Delphi le compare a
FPinSealed - ce sous quoi le cache est REELLEMENT scelle, pose seulement
par une ouverture reussie, jamais par un champ.

Fermee au passage : SetUnlockCode('') retirait le code en silence, ce qui
contournait le controle qu'on vient de poser.

Le FBusy de ClearUnlockCode ne sort plus sans un mot - une sortie muette
se lit comme une reussite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:30:30 +01:00
r-zakarya 203f45eb7c fix(android): les refus se voient, et la croix se recale sur le champ
Un refus s'affichait du meme gris qu'un etat : "Master password: 8
characters at least" se lisait comme "Vault on this device", et rien ne
disait que l'application venait de refuser. StatusErr le met en rouge -
deux procedures plutot qu'un drapeau devine du texte, parce que trier des
messages sur leur contenu a deja detruit un cache dans ce projet. C'est
l'appelant qui sait s'il refuse.

La croix des champs etait calee a top: 8px, en compensation du margin-top
du champ. Les piles annulent ce margin depuis qu'on y a unifie
l'espacement, et la croix restait donc 8 px trop bas. Elle s'ancre
desormais au bord INFERIEUR, le seul que le champ touche dans les deux
cas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:25:07 +01:00
r-zakarya 494efddb44 feat(android): le mot de passe de creation ouvre le coffre
Il ne servait qu'une fois, a la creation : le cache s'ouvrait ensuite
sans rien demander, et n'importe qui tenant le telephone deverrouille
lisait le coffre.

Il est desormais pose comme code de deverrouillage - c'est exactement ce
que ce format sait faire, et il accepte un mot de passe entier aussi bien
que des chiffres depuis qu'on a retire le clavier numerique. Le cache
porte donc VER_PIN, la seule forme qui survive aussi a une
reinitialisation du verrouillage d'ecran.

Retirable en deux appuis depuis les reglages, pour qui prefere
l'ouverture immediate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:14:54 +01:00
r-zakarya 9b26a1dbd0 fix(android): un coffre vide n'est pas un coffre ferme
"Coffre ouvert" se lisait sur le nombre d'entrees. Or un coffre vide en
compte zero, exactement comme un coffre ferme - et le coffre d'essai
l'est. Le garde de DoSync restait donc vrai apres l'ouverture, rearmait
la reprise, et l'on tournait en rond : le journal repetait "Vault opened
from cache", l'interface clignotait et ne repondait plus.

Le meme idiome se trouvait a deux autres endroits. Le second compte :
OnIdleTimer se coupait de lui-meme sur un coffre vide, qui restait donc
ouvert indefiniment - et vide aujourd'hui ne veut pas dire vide dans dix
minutes.

FPlain, le clair en main, est le seul signal qui ne mente pas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:12:20 +01:00
r-zakarya af28788a0a fix(android): Sync ouvre le coffre des qu'il y en a un, pas seulement sous code
Le garde ne couvrait que le cache scelle par un code. Depuis que les deux
secrets sont separes, la reconciliation a besoin de celui du conteneur
local - qui ne se pose qu'a l'ouverture - donc un coffre ne ici refusait
aussi, avec "open vault first", et ouvrir ensuite ne relancait rien : le
bouton Sync ne vit que sur l'accueil, et le coffre ouvert on est sur la
liste.

Un seul garde desormais : s'il y a un cache lisible et que le coffre
n'est pas ouvert, on ouvre et on reprend la synchronisation. Un appareil
sans cache n'est pas concerne - c'est le premier telechargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:07:25 +01:00
r-zakarya dd44a164f6 fix(android): le cache retient le secret qui ouvre CE coffre
La separation laissait FVaultPwd vide apres une ouverture depuis le
cache - seules la creation et la connexion le posaient - et la
reconciliation refusait donc en boucle, "open vault first" alors que le
coffre etait ouvert.

Le cache porte un secret et un seul : celui du conteneur local pour un
coffre ne ici, celui du depot pour un coffre publie. Les deux ne
coexistent jamais, puisque la reconciliation detruit le conteneur local
en passant l'autorite au depot. CachePwd choisit a l'ecriture, et la
relecture le range dans la meme case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:04:04 +01:00
r-zakarya ed7ccdd2d6 feat(android): deux secrets separes, comme sur le poste
Le mot de passe de creation scellait le conteneur local ET pretendait
sceller l'instantane du depot. Un coffre cree ici publiait donc sous son
propre mot de passe, que le poste ne savait plus ouvrir - et le
rafraichissement echouait avec "wrong password" sur un depot pourtant
intact.

FVaultPwd ouvre le conteneur local et s'arrete la. Le secret du depot vit
dans les reglages, doit etre le meme sur tous les appareils, et c'est lui
seul qui ouvre et rescelle l'instantane.

Les deux ne se rencontrent qu'a UN endroit, la reconciliation : le
conteneur local s'ouvre sous le sien, puis la cle est jetee avant de
toucher au depot - la garder rescellerait le depot sous le mauvais
secret. Ailleurs ils ne se croisent jamais : un coffre ne ici ne publie
pas, un coffre publie n'a plus de conteneur local.

Migration : rien a rechiffrer. Les coffres existants ont simplement les
deux valeurs egales, et cessent d'etre forces de le rester.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:58:17 +01:00
r-zakarya 17cfc11084 fix(android): le mot de passe des reglages suit le coffre a CHAQUE ouverture
La correction precedente ne portait que sur la creation et la connexion
locales, qui n'arrivent qu'une fois. Le chemin reellement emprunte est
l'ouverture depuis le cache, a chaque lancement : PushConfig y reposait
la valeur de premier lancement, l'ouverture ne la remplacait pas, et
DoSync la prefere a celle du coffre. Les reglages continuaient donc
d'afficher TEST_PWD.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:44:54 +01:00
r-zakarya ea2450abfd feat(android): dire pourquoi le cache est illisible, et accepter un mot de passe comme code
"Local vault unreadable" laissait croire a une corruption, donc a une
perte, alors que le coffre est intact dans le depot. Quand Android a
lui-meme detruit la cle - verrouillage d'ecran retire ou reinitialise -
l'application le dit maintenant, et dit aussi que rien n'est perdu.

Le tri passe par un drapeau pose la ou l'invalidation se constate, pas
par un test sur le texte du message : c'est ce tri-la, fait sur des
chaines, qui detruisait autrefois le cache en se trompant.

Le code de deverrouillage accepte un mot de passe, et l'a toujours
accepte - aucune validation n'a jamais impose des chiffres. Seul
inputmode="numeric" le laissait croire, et le clavier numerique
decourageait d'essayer. Clavier complet, libelles et note mis a jour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:24:29 +01:00
r-zakarya 8b4130f13e fix(android): les reglages affichaient un mot de passe qui n'etait pas celui du coffre
PushConfig pose FPwd a la valeur de premier lancement - en debogage une
constante, en release du vide - et rien ne la remplacait ensuite. Creer
ou ouvrir un coffre local mettait a jour FSyncPwd seul, si bien que les
reglages continuaient d'afficher l'autre.

Ce n'etait pas qu'un affichage : DoSync PREFERE FPwd a FSyncPwd, au nom
de "ce qui est tape l'emporte" - or rien n'avait ete tape. Les deux
chemins de synchronisation, le rafraichissement de fond et le bouton,
travaillaient donc avec deux mots de passe differents sur le meme coffre.

Le coffre qui vient de s'ouvrir dit desormais son mot de passe, et les
reglages le montrent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:06:27 +01:00
r-zakarya a81fd88414 fix(android): la page revenait pas apres un verrouillage hors du coffre
Le masquage ne demande rien - il suffit de ne pas etre sur l'accueil pour
que la vue disparaisse avant la capture des applications recentes, coffre
ouvert ou non. La restitution, elle, exigeait un coffre ouvert.

Consulter les REGLAGES coffre ferme, puis laisser le telephone se
verrouiller, cachait donc la page pour de bon : au retour il ne restait
que le fond de la fenetre, un aplat bleu sans rien dessus et sans rien
pour en sortir.

La vue revient maintenant dans tous les cas, et APRES le verrouillage
eventuel pour ne jamais montrer une liste qu'on vient d'effacer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:58:18 +01:00
r-zakarya 1a19c838cd fix(android): un seul espacement entre les elements d'une pile
Un champ porte son propre margin-top de 8 px, dont il a besoin dans les
reglages ou les champs se suivent en flux normal, sans gap. Dans une
pile, ce margin s'ajoutait au gap : deux champs se retrouvaient a 16 px
l'un de l'autre, un champ et un bouton a 8. Dans une pile, c'est le gap
qui commande.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:51:45 +01:00
r-zakarya ab5eeb60f5 fix(android): l'accueil se lit de haut en bas, et ses blocs respirent
L'espace de 8 px vit A L'INTERIEUR de chaque pile. "Create vault" et
"Download vault" etaient dans deux piles voisines et se touchaient donc,
tandis que "Download vault" et "Settings", dans la meme pile,
respiraient. Un margin entre piles adjacentes suffit.

L'ordre suit maintenant les frequences et non l'ordre du code : aller
chercher le coffre, regler, creer en dernier. La creation etait en tete
parce que son bloc etait ecrit en premier dans la page.

L'accent suit le geste attendu - il va a "Download vault" des qu'un depot
est configure, et ne reste sur la creation que lorsqu'il n'y a rien a
aller chercher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:42:54 +01:00
r-zakarya 900f5ba740 fix(android): poser, changer et retirer le code exigent un coffre ouvert
Deux branches de l'ouverture posaient le code a partir de ce qui trainait
dans les champs.

La premiere appliquait un "nouveau code" depose par les reglages. Elle
n'exigeait rien de celui qui le deposait : n'importe qui tenant le
telephone deverrouille pouvait en poser un, et c'est le proprietaire qui,
en ouvrant son coffre, l'appliquait contre lui-meme.

La seconde rescellait sous le code tape des que le cache ne l'etait plus,
ce qui DEFAISAIT LE RETRAIT : on retirait le code, le champ le portait
encore, et l'ouverture suivante le reposait sans qu'un mot le dise. Le
premier octet du cache le disait - VER_PIN apres un retrait annonce
reussi.

Poser et changer passent maintenant par SetUnlockCode, jumeau de
ClearUnlockCode : geste explicite, coffre deja ouvert - donc code courant
connu - et effet immediat. Le champ de l'accueil ne veut plus dire qu'une
chose, "ouvre avec ceci", et ne parait que lorsqu'un code est pose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:17:24 +01:00
r-zakarya 45564e7266 fix(android): un seul bouton sur l'accueil, et il ne ment pas sur sa destination
Il y en avait deux, "Settings" et "Log", et le second a fini par ouvrir
le premier depuis que le journal a demenage dans les reglages. Un bouton
nomme "Log" qui ouvre "Settings" se ment sur son etiquette.

Settings redevient VISIBLE EN PERMANENCE. Le cacher des qu'un coffre
existe laissait l'accueil sans aucune porte vers les reglages - et c'est
precisement quand le coffre refuse de s'ouvrir, code faux ou adresse
fausse, qu'on en a besoin avec le journal. Ce n'est pas un doublon de la
roue dentee : celle-ci est DANS le coffre, ce bouton est sur l'accueil,
jamais cote a cote.

En release, c'est desormais le bouton des reglages qui s'efface, l'appui
long sur la ligne d'etat le ramenant comme avant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:09:17 +01:00
r-zakarya fe00fe8484 fix(android): le journal se deplie dans les reglages, la ou on le demande
Il n'affichait rien, et pas parce qu'il etait vide. togglePanel
parcourait ['cfg','log'] et cherchait pour chacun le bouton qui le
commande - or btnCfg a perdu son data-panel le jour ou les reglages sont
devenus un ecran. La premiere iteration levait sur un null, et le journal
n'etait jamais deplie, quel que soit le chemin.

Il vivait de plus sur l'accueil : "Show the log" refermait les reglages
pour le deplier derriere, c'est-a-dire sur un ecran qu'on ne regarde plus
des que le coffre est ouvert. Il est maintenant DANS les reglages, deplie
sur place - le seul ecran atteignable depuis l'accueil comme depuis le
coffre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:55:06 +01:00
r-zakarya 6c5fb2f4ea fix(android): Sync ouvre puis synchronise, et l'accueil ne demande plus le nouveau code
Le garde pose juste avant fermait la seule porte : le bouton Sync ne vit
que sur l'accueil, et une fois le coffre ouvert on est sur la liste, ou
il n'existe pas. Synchroniser devenait impossible des qu'un code etait
pose. Sync ouvre donc le coffre - ce qui verifie le code, seul LoadVault
le peut - et OpenLocalNow reprend la synchronisation ou elle s'est
arretee. Le drapeau est consomme des sa lecture, pour qu'un code faux ne
laisse pas une synchronisation en embuscade.

Le champ "new unlock code" quitte l'accueil : les reglages posent et
changent le code, il y faisait doublon sur l'ecran ou l'on veut seulement
ouvrir. Il reste dans la page, cache - c'est lui que "Set this code"
remplit avant pushConfig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:50:15 +01:00
r-zakarya 138abea88d fix(android): Sync ouvrait le coffre sans demander le code
Le garde de DoSync ne couvrait que l'empreinte, et les deux protections
s'excluent : un cache scelle par un code porte VER_PIN, donc
VaultNeedsAuth y est FAUX. Synchroniser reconstruisait le coffre depuis
le depot et l'affichait sans que le code soit demande - il ne gardait
qu'un bouton sur deux.

Exiger un champ rempli n'aurait rien valu : le depot est scelle par le
mot de passe de sync, pas par le code, donc rien ici ne le confronte a
quoi que ce soit et n'importe quelle frappe serait passee. Seul LoadVault
sait si le code est le bon, et c'est "Open vault" qui l'appelle - d'ou le
renvoi vers lui.

Place AVANT ReconcileWithRemote et PublishPending, qui sortent de DoSync
par leurs propres chemins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:41:36 +01:00
r-zakarya ac3e9db4d9 feat(android): l'accueil ne repete plus Settings quand le coffre est la
La roue dentee de la liste mene au meme ecran : le repeter sur l'accueil
n'encombrait que l'accueil.

Mais pas de suppression pure et simple. Sans coffre local, la liste
n'existe pas - donc la roue dentee non plus - et il faut bien pouvoir
saisir l'adresse du depot pour aller le chercher. Le bouton parait donc
exactement quand "Open vault" ne parait pas, sur le meme signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:36:49 +01:00
r-zakarya 35d0d8ddf3 feat(autofill): la barrette revient allumee si elle l'etait
Pref DPAPI lazyOn, reprise par RestoreLazyBar depuis FormCreate - apres
le theme, parce que la barrette se peint elle-meme, et apres le cablage
de OnTrayRestore, dont depend son bouton Open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:26:18 +01:00
r-zakarya 0604cca148 feat(autofill): un bouton Open, et le menu ne passe plus sous la barrette
Quand la barrette est allumee, elle est souvent la seule chose de
l'application a l'ecran : revenir au coffre ne doit pas demander de
retrouver l'icone du tray.

Le menu d'opacite passait derriere : le battement qui repose la barrette
en tete de la bande TOPMOST cinq fois par seconde recouvrait le menu que
la barrette venait d'ouvrir - lui aussi dans cette bande. TrackPopupMenu
est modal, donc le minuteur battait pendant tout ce temps. Il s'arrete
maintenant le temps du menu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:36 +01:00
r-zakarya 6b908e73a5 fix(autofill): la barrette reste au-dessus, et se traine depuis ses boutons
TOPMOST n'est pas un rang, c'est une bande. La barre des taches y est
aussi, et cliquer son fond vide la remonte en tete de cette bande : une
barrette posee dessus passe DERRIERE et semble avoir disparu. Le meme
battement de 200 ms la repose.

Traine depuis un bouton : les boutons sont subclasses, et DragDetect
tranche entre clic et glissement en respectant le seuil regle par
l'utilisateur. Sur un clic, on envoie BN_CLICKED a la place du bouton -
DragDetect a avale le WM_LBUTTONUP, lui rendre la main le laisserait
enfonce pour toujours.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:27:38 +01:00
r-zakarya 9ce07ae5d0 feat(autofill): la barrette revient la ou on l'avait posee
Pref DPAPI lazyPos, ecrite sur WM_EXITSIZEMOVE - une ecriture par
deplacement, pas une par pixel parcouru.

La position n'est reprise que si MonitorFromRect la trouve encore sur un
ecran. La barrette n'a pas d'entree dans la barre des taches : une
position heritee d'un ecran debranche, ou d'une resolution changee, la
laisserait hors du bureau et definitivement irrecuperable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:20:02 +01:00
r-zakarya 7fa26858e3 feat(autofill): la barrette s'efface quand la souris la quitte
Clic droit dessus -> 100/75/50/25 %, persiste en pref DPAPI. Pas dans
settings_json : la barrette peut etre allumee coffre ferme, ou aucune
session n'existe pour lire les reglages du serveur.

Le survol est sonde toutes les 200 ms plutot que suivi par
TrackMouseEvent : avec quatre fenetres enfants, passer de la barrette a
un bouton envoie deja un WM_MOUSELEAVE au parent, et il faudrait suivre
la souris sur les cinq fenetres pour recoller les morceaux.

Le menu appartient a la fenetre message-only : TrackPopupMenu reclame un
SetForegroundWindow pour se refermer seul, et le faire sur la barrette
detruirait le NOACTIVATE qui la fait fonctionner.

Transparente n'est pas traversante - les clics sont toujours avales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:14:15 +01:00
r-zakarya f7665b4225 feat(autofill): fermer la barrette depuis la barrette, et des libelles lisibles
Un quatrieme bouton X. Il POSTE WM_LAZY_CLOSE a la fenetre message-only
plutot que de detruire sur place : liberer une fenetre depuis son propre
gestionnaire libererait, en plein envoi, l'instance qui lui sert de
window proc.

Au passage, deux libelles sortaient en mojibake - "Quick search__|" dans
le menu du tray, un tiret cadratin dans l'infobulle. Le fichier est en
UTF-8 SANS BOM, que Delphi lit en ANSI, un octet par caractere : les
litteraux restent donc en ASCII.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:52:50 +01:00
r-zakarya 52f5024de0 feat(autofill): un troisieme bouton 2FA, et la barrette suit le theme
SetPreferredAppMode(ForceDark) ne themise que ce que le systeme dessine
lui-meme - menus, ascenseurs, infobulles. Un bouton Win32 standard reste
donc gris clair sur fond sombre, ce que la capture montrait.

BS_OWNERDRAW + WM_DRAWITEM : le systeme garde le suivi de la souris et
l'etat enfonce, nous ne reprenons que les couleurs, recopiees des
variables de css/style.css. Le cadre passe dans WM_ERASEBKGND plutot que
par WS_BORDER, qui se sert dans les couleurs systeme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:49:49 +01:00
r-zakarya 09bbd3fe9e feat(autofill): lazy mode - une barrette de deux boutons pour remplir a la souris
Menu du tray -> "Lazy mode". Deux boutons Full / Password qui flottent
au-dessus de tout et font ce que font Ctrl+Shift+L / Ctrl+Shift+P.

WS_EX_NOACTIVATE est toute l'astuce : une fenetre ordinaire prendrait le
premier plan au clic, le champ que l'utilisateur vient de cliquer perdrait
son curseur, et GetForegroundWindow nous designerait nous-memes comme cible.
Le clic ne deplace donc jamais le premier plan.

Le corps du handler WM_HOTKEY est extrait dans FireAutofillRequest : hotkey
et barrette partagent la capture de la cible, le matching de titre et le
picker. Un seul chemin, deux gestes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 15:42:49 +01:00
r-zakarya ba4ab18d0f feat: the last three passwords travel, and the phone can show them
The desktop keeps twenty previous versions per entry and never sent any. It
now sends three.

Three, not twenty, because the trade is not symmetrical. On the desktop they
sit encrypted column by column and never move. In the snapshot everything
travels in the clear inside the container, and comes down into a phone cache
sealed only by the device key - not by the master password. An old password
stays sensitive: it is often still valid somewhere else. The real use of the
list is short - "I changed it recently, what was it?" - and three cover it.
The other seventeen would be copies with no need facing them.

The phone reads them ON DEMAND from the raw snapshot, never into TVault:
loading them with the vault would put every entry's old passwords in the clear
in memory from the moment it opens, for something one looks at now and then.

Copying goes by RANK, not by value. The page displays the text but never sends
it back - that is the bridge's rule everywhere else, and an old password has
no reason to be the exception just because it happens to be on screen.

They leave with the plaintext at lock, on both sides.
2026-08-22 16:14:04 +01:00
r-zakarya 61c52e0edd docs(android): what is left is mostly testing, and the plan says so now 2026-08-22 15:55:37 +01:00
r-zakarya 2010dec22a feat(android): an entry can get its icon, through the same relay as the desktop
Fetching https://<site>/favicon.ico directly would resolve a DNS name - and
open a connection - to EVERY domain kept in the vault. The list of sites where
someone holds an account is precisely what this vault protects; handing it to
the network for a sixteen-pixel drawing would be a poor trade. The relay sees
only that someone is interested in one domain, which is why the call is asked
for entry by entry and never happens on its own.

Two tries: the full host, then the second-level domain - chat.qwen.ai has no
icon at the relay, qwen.ai does. The type comes from the first bytes, never
from the extension: the relay answers PNG under a .ico address.

A dedicated patch rather than PatchEntryField, because icon_custom is a NUMBER
in the snapshot and writing it as a string would give "0" - which the desktop
reads with GetValue<Boolean> and would choke on. It is set to zero: the icon
comes from an automatic lookup, not from a choice, and that flag is what stops
the desktop from ever replacing a chosen icon during a refresh.

Base64 without line breaks. Delphi's encoder wraps at 76 characters by
default, a wrapped data: URI does not display, and that trap has already been
paid once on the containers.
2026-08-22 15:39:19 +01:00
r-zakarya 73e3faa24c feat(android): a settings screen, and the unlock code can finally be removed
Three things of different natures lived in collapsible panels on the home
screen - repository credentials, the unlock code, the log - on the one screen
where one mostly wants to open one's vault. They move to a settings sheet,
reachable from the list too.

A sheet, not a fourth view of the rail: each view costs its transform rules
and a place in back-key navigation, and settings is not somewhere one moves
about - it is a parenthesis one opens and closes.

The unlock code can now be REMOVED. It could not before: clearing the field
meant "nothing typed", not "drop the protection", so one could set a code and
never get out. It requires an open vault, and that is the proof being asked
for - the cache only let itself be read under the right code. Without that,
anyone holding the unlocked phone could lift the protection without knowing
it.

The refusal that guards every other path stays: it is what stopped a sync
from silently undoing a code. Removing is the one deliberate way out, and it
says so at the call site.

The old fields stay in the document as the source Delphi knows; the sheet is
only a view of them. Emptying them would lose the configuration at the first
sync.
2026-08-22 15:34:40 +01:00
r-zakarya 4ae845bc13 feat(android): tell when the repository has moved, without adopting it
A probe every ten minutes compares the remote fingerprint to ours and lights
an indicator. It does not pull: adopting on its own while someone is reading
an entry would change it under their eyes. The user decides when.

The body is downloaded anyway - Gitea's contents API cannot answer without it
- but it is thrown away undecrypted: no derivation, no write, just a string
comparison. The cost is network, not time or battery.

It rides the idle timer rather than a second one. That timer already beats
every 15 seconds for auto-lock; counting its beats costs an integer, where
another timer costs an object, a lifecycle, and one more chance to forget it
on shutdown.

Two indicators, two colours, two gestures. Orange: things wait HERE and the
repository does not know. Blue: the repository holds things we do not have.
One dot for both would say "something is wrong" without saying what - and one
is resolved by publishing, the other by fetching.
2026-08-22 15:28:41 +01:00
r-zakarya 92ceb1e8c9 fix(android): the merge lost folders, and the pending dot was on one screen
Two defects found by re-reading before adding more, along with a test plan.

MergeSnapshots merged entries and tombstones but not folders. A folder created
on the phone - offline, or just before another device published - vanished
from the list while the entries kept its name: filed under a folder nothing
displays, unfindable without knowing it. That is exactly what EnsureFolder was
written against, come back through another door.

The pending dot sat on the padlock, which only exists in the list. After
locking one lands on the home screen - precisely where one decides to sync -
and it said nothing there. It is on Sync as well now.

TESTS.md records what the reading checked and what it left alone, with reasons
for the two defects deliberately not fixed, and a scenario that says what each
step must PRODUCE rather than only what to do.
2026-08-22 15:24:01 +01:00
r-zakarya af2a0dfed8 fix(android): offline changes survive a refresh, and a restart
Two defects in what offline saving had just introduced, both losing data
silently - found by re-reading rather than by testing, since the device was
away.

The background refresh REPLACED the cache with the repository's version. An
edit made offline vanished at the next fetch, without a word, which is
exactly what offline saving promises to prevent. It merges now, and keeps the
flag: merging is not publishing.

And the flag lived only in memory. An application killed between the offline
save and the publish forgot it had anything to send - and the next refresh
then clobbered the cache. It is a file now, read before any refresh can run.

A file rather than a field of the sealed cache: the flag carries no secret,
its whole content is yes or no, and the cache's signature is already nine
parameters long.
2026-08-22 15:15:21 +01:00
r-zakarya a24b18e90f feat(android): saving works without a connection
The save used to start from the network: without it, nothing was kept. But
what someone has just typed is already theirs - publishing is a consequence,
not a condition. The cache is written first, always, and the repository
follows when it can.

A failed publish no longer loses anything. It raises a flag, and the toast
says both things: that it is kept, and that the repository does not know yet.
A bare "failed" would have people retype what is already there.

On conflict the two snapshots are now MERGED rather than the change replayed.
Replaying one edit onto the fresh snapshot was right while writing required
being online; with offline saving several changes can be waiting here, and
replaying one would drop the others.

Sync publishes what is pending BEFORE fetching. Fetching first would overwrite
our cache with the repository's version and the offline change would vanish
without a word - exactly what offline saving promises to prevent.

A dot on the padlock says changes are waiting: discreet until looked for,
present at every glance.
2026-08-22 14:56:04 +01:00
r-zakarya 248cdafa49 fix(android): a note can be written on several lines, and Enter means Enter
The creation form still put a note's body behind a single-line input, so one
could read and edit multi-line text but never create it - the note had to be
created, then reopened to be laid out.

And the newline could not be typed at all: the handler that turns the
validate key into "fold the keyboard" applied to every field. That rule
exists because in a one-line field the key can only jump to the next one, and
the next one sat on an off-screen view - which is how the unclosable keyboard
happened. In a text area, Enter means Enter. It is the third time that
handler has misfired by treating "any field" as one thing; it now carries its
three exceptions explicitly.

Two things fixed in the same code, since they lived there:

Rows were recognised BY THEIR LABEL - prose. Renaming a caption would have
been enough for this code to stop acting on it, silently. They go by id now.

And what is already typed survives a change of kind. Replacing the control
without carrying the value over would have wiped the entry for a mere change
of mind between Login and Note.
2026-08-22 14:33:58 +01:00
r-zakarya 4672ba8fd8 fix(android): a note's line breaks show in view, not only in the editor
white-space defaults to normal, which folds newlines into spaces: the body of
a note read as one run-on paragraph, while the editor showed it correctly.
The formatting looked lost until you opened the pencil - the worst kind of
mismatch, since it invites re-typing what was already there.

The same rows also drop break-all, which cuts mid-word. That suits a password
and not prose; they break only where they must.
2026-08-22 11:48:54 +01:00
r-zakarya 8315ce9230 fix(android): editing a note was about to strip its line breaks
Making every field editable put a note's body behind an <input>, and HTML's
value sanitization removes CR and LF without a word. The formatting would
have been lost on the phone and published to the repository at the next
save - silent, and irreversible once the desktop pulled it.

Multi-line is marked on the row in Delphi, like the field name, rather than
guessed from the caption or from whether the value happens to contain a
newline today: a single-line note one wants to make multi-line deserves the
same control.

The row wraps through a class set when the editor opens, not through :has() -
that selector does not exist before Chrome 105, and an older engine would
ignore the rule without saying so.
2026-08-22 11:33:10 +01:00
r-zakarya 0fa521b709 docs(android): the plan contradicted itself about section 1, and the order was stale
Section 1 still said its points had never been exercised while a block below
recorded four of them as validated. Only point 4 remains - and the unlock
code has largely defused it: while a code is set the cache is sealed under an
unconstrained AES key that no screen-lock change invalidates, so the
authenticated branch never runs. That is what the code was built for.

The proposed order listed three things that are done. Replaced by what is
actually left.
2026-08-22 10:30:13 +01:00
r-zakarya 3e0bc7bd84 build(android): build and sign a release, without the password touching anything
build-apk.ps1 -Release builds the Release configuration and signs with the
user's keystore. Not through Delphi's Provisioning page, for three reasons:
the .dproj is versioned, paclient --apksign puts the password ON THE COMMAND
LINE where any process on the machine reads it, and tooling belongs in the
repository rather than hidden in an IDE setting. apksigner called directly
accepts --ks-pass env:.

The password is asked for with Read-Host -AsSecureString - nothing echoed,
nothing left in console history - handed over through an environment
variable, and removed in a finally even when the script fails.

It prints the certificate it signed with. A release signed by accident with
the debug key installs and runs: nothing tells it apart in use, and one finds
out only when the first update is refused.

It does not install the release. Android refuses a different signature on an
existing package, and uninstalling would wipe the local cache and the
device's settings - a deliberate act, not a script's side effect.

Keystores are gitignored. The debug path is unchanged and still builds.
2026-08-22 01:05:42 +01:00
r-zakarya 6d8f36b5c7 docs(android): saving is down to 1.15 s, and the rest is the round trip
267700 chars became 50457, and a save 2.8 s became 1.15 s. But the payload
fell 5.3x while the time fell only 2.4x: what remains is no longer the size,
it is the trip - TLS handshake, round trip, Gitea creating the commit. A
fixed six to eight tenths of a second whatever the load.

That is this transport's floor, and it closes the question. The other two
routes are no longer worth it: publishing in the background would remove one
second of waiting at the price of "saved" ceasing to mean "published", and
compression would change the format on both sides to shave a share that has
become the minority.
2026-08-22 00:59:50 +01:00
r-zakarya b245f223f2 docs(android): the save cost, resolved by the cheapest route and measured
Attachment removed from the vault on the desktop, then a sync: 266299 bytes
became 49268. It weighed 217031 bytes - 81% of everything that travelled on
every write, against an estimate of 78%. The fetch fell from 3244 ms to
597 ms.

The phone can now save an attachment to Downloads, so it cost nothing. Worth
keeping: the weight of a synced vault is its attachments, and since the
container is monolithic, each one is paid on every write of any entry.
2026-08-22 00:55:58 +01:00
r-zakarya 405d295523 docs(android): the phone cannot remove an attachment, and patching would not hold
Worth recording before someone tries. The desktop restores attachments only
for a NEW entry; for an entry it already knows, the merge leaves them alone.
Publishing a snapshot without the attachment would leave the row untouched in
its database, and its next buildSyncSnapshot would put it back - the removal
would undo itself on the following pull.

So removing an attachment from the synced vault is a desktop action. The
phone can now save it first, which makes that lossless.
2026-08-22 00:49:06 +01:00
r-zakarya 40f7667243 docs(android): two plan entries had gone false during the session
The URL and token no longer live as constants - they are in the
Keystore-encrypted settings file once the device has saved them, and the
debug-only preload is a recorded decision rather than an oversight.

And section 4's open list described a state that no longer exists: the 409
has run (it was a 422), every field is editable, and create, duplicate and
delete exist. What remains there is one deliberate choice - no offline
editing - and MergeSnapshots now exists, which lowers its price.
2026-08-22 00:43:42 +01:00
r-zakarya 6b3001666e feat(android): attachments can be saved, section 3 item 1 is done
Two questions, and the code was only the second one.

WHERE THE FILE LANDS. The private directory is useless - no file manager
opens it and it leaves with the uninstall - and direct external storage has
been closed since Android 10. Of the three remaining doors, MediaStore ->
Downloads is the only one needing neither a permission nor an extra screen:
since API 29 an app may write what it inserted itself. The destination picker
would need an activity result, which FMX makes painful; sharing would need a
FileProvider in the manifest. Below API 29 it would take
WRITE_EXTERNAL_STORAGE - one of the ten permissions just removed - so it
refuses and says so.

WHERE THE CONTENT IS. TVault drops it on purpose: a single attachment took
the cache from 13 to 174 Ko. It is re-read from the raw snapshot, which has
been in memory ever since saving stopped fetching before publishing. The same
data now serves two needs that had nothing to do with each other.

Decoding and writing happen off the main thread, under the waiting veil: a
few megabytes would freeze the screen, and the veil would not even paint.

What leaves the application leaves its protection: the file lands in the
clear in Downloads. The confirmation names the place rather than saying
"saved".

Release configuration compiles too - checked, since that is what section 5
caught out.
2026-08-22 00:41:05 +01:00
r-zakarya cb2e2c7a5f docs(android): record the body-state name collision next to the icon trap 2026-08-22 00:31:56 +01:00
r-zakarya 2e84c8a7f8 fix(android): the busy veil was switching the whole page off
An empty dark blue screen, which is the window's own background with nothing
drawn on it. The cause is one name used twice: the veil element carries class
`busy`, and the state was put on the body under the same name - so
`.busy { display: none }` applied to the BODY as well, and the page vanished
the moment any operation started.

The state is now called `is-busy`, which nothing else carries. Checked the
other body states - typing, selecting, searching, on-list, on-detail - none
of them names an element, so none has the same trap.
2026-08-22 00:23:34 +01:00
r-zakarya 5df1c7e8e8 feat(android): say that work is happening, on every screen
The home screen's buttons already dimmed during an operation, but from the
list nothing moved: deleting or duplicating takes a second or two - the
container's round trip - and the screen stayed identical, as if the tap had
not registered. A veil with a turning arc now covers any screen, and it also
prevents the second tap, which would not wait any better.

Its safety net is the existing 25 s deadline: even if an operation never
hands back, the veil lifts.

The plan records where deleted entries actually are: every publish is a Gitea
commit, so the snapshot from before the deletion is still in the repository's
history, readable in its web interface and recoverable by import. Not a
screen, but a real net - and one that catches what a trash would not: an
unlucky edit, a bad import.
2026-08-22 00:18:07 +01:00
r-zakarya ebd4752ec9 feat(android): a selection bar in the list, and Duplicate everywhere
The floating menu was wrong for a list. It closed on the next tap, so opening
an entry afterwards took two gestures and felt like a button had got stuck.

Android's contextual bar instead: a long press selects, the top bar takes the
place of search, says how many rows are held, and offers duplicate and
delete. It goes away when nothing is held. One does not search while choosing
what to delete, and two rows of controls would fight over the same thumb.

Selection is multiple, because the cost is in the bar, not in the work: the
patch closure loops, and ten deletions become ONE publish. The container goes
up whole on every write - deleting ten entries one by one would pay that trip
ten times, and give nine chances to fail halfway.

A tap during selection picks rather than opens, which is also the way out:
unpicking the last row closes the bar. The hardware back cancels the
selection before locking the vault - locking because someone wanted to cancel
a choice is the opposite of what the gesture means.

Both menus stay, with distinct jobs: the bar acts on rows chosen in the list,
the detail's three dots act on the entry in front of you. Duplicate copies
everything, attachments included, as the desktop does - a copy that silently
lost the attached document would be a surprise discovered too late.
2026-08-22 00:10:38 +01:00
r-zakarya 0619740da1 fix(android): delete from the list, and stop reopening what was deleted
Two things, and the second was me misreading the question: the affordance was
asked for in the LIST, not in the opened entry.

A long press on a row opens the same menu. Not a cross per row - an
irreversible action a thumb's width away on a list one scrolls - and not a
third button per row, when those were just cleared away. A long press is
invisible until used, it is Android's convention for acting on a list item,
and it is where multi-select would go if it ever comes.

And the deleted entry reopened itself. editDone hands control back after a
refusal, but it redrew the LAST detail - after a delete the view has already
returned to the list, so the entry one had just erased came back on screen.
It now hands back to what is on screen, not to what was.

Delphi deletes by rank when the list asks, by the open entry otherwise -
exactly like open and copy. The page never sends a uuid: it has one, but
letting it name a row Delphi never displayed is a door with no reason to be
open.
2026-08-21 23:51:09 +01:00
r-zakarya 00d5b582cc feat(android): delete moves into the entry's menu
It was a full-width red button at the foot of every detail, permanently in
view for a gesture one makes three times a year. It now lives behind the
three dots in the header: open, then confirm - two deliberate gestures, and
nothing lingering in sight the rest of the time.

A single item in a menu is not an oversight. The action has no way back, and
an action with no way back should not sit where a stray tap reaches it. A
cross in the corner would be worse still: that corner means "close"
everywhere else.

The plan records what the server code says about the trash: receiving a
tombstone runs DELETE FROM vault_entries, a hard delete. A deletion from the
phone removes the row on both sides and bypasses the desktop's trash. The
only net is the desktop's backups, and there is no trash to show here - the
phone never receives its contents.
2026-08-21 23:40:35 +01:00
r-zakarya a3a6d8b0f8 fix(android): a dropdown raises no keyboard, so nothing should move
Focusing the Folder field made the screen jump: every .field-in was treated
as if a keyboard were coming, reserving 60% of the height and lifting the row
to the top of the panel. A <select> opens a native list over the page - it
occupies nothing and vanishes on choice.

And the lift threshold now says what the keyboard actually covers, a little
under the bottom half. At 40% it raised fields that stayed perfectly visible,
moving under the eye what was already fine.
2026-08-21 23:29:06 +01:00
r-zakarya 1f9606fed4 docs(android): why the clipboard chip appeared late, and why the fallback cannot 2026-08-21 23:24:01 +01:00
r-zakarya d06410dd7e fix(android): emptying the clipboard is not copying into it
The white "copied" chip appearing long after the gesture was Android's, and
it was ours to stop causing: the 45 s auto-clear wrote an empty string
through setPrimaryClip, which the system reads as a copy and announces like
one. The comment claimed the visible result was the same. It is not, and the
API has distinguished emptying from copying since 2018 - clearPrimaryClip,
kept behind an API 28 check because minSdk is 23.

It only showed every other time because the timer clears only if the
clipboard still holds our text; copying anything else in between cancels it.

Nothing to fix on the sensitive flag: it is set, the chip shows no content,
and that generic wording is exactly what the flag produces.
2026-08-21 23:04:03 +01:00
r-zakarya b1f84cb977 style(android): the inline editor's buttons become icons too
Show, Cancel and Save shared their row with the input field, and three words
left barely enough room to read what one types - the very crowding that made
the row overflow in the first place. A cross and a tick read without
translation.

The tick gives way to a turning arc while saving. The word "Saving..." said
the wait was real; a motionless tick would say nothing, and it is exactly
during that second or two that one presses again.

The form's Save and Delete entry keep their words: they are not inline
controls sharing a row, and the second is the only action with no way back.
2026-08-21 22:41:55 +01:00
r-zakarya 4ba4c20dc3 docs(android): record the icon-button trap before the next one is added
With an icon inside, the tap target is the svg or one of its strokes, never
the button. Reading the attribute off the target returns null and the handler
falls through to its default - here, opening the entry instead of copying.
Half the taps worked, which is the worst kind of failure to diagnose.
2026-08-21 22:35:29 +01:00
r-zakarya 68722e301c style(android): the icon buttons lose their chip, keeping their target
Three translucent pills per list row weighed more than what they pointed at.
The drawing alone carries it.

The target stays 44 px: that is what a finger expects, and it does not need to
be visible to exist. The press feedback moves from the background to the
stroke, since there is no background left to dim.
2026-08-21 22:21:26 +01:00
r-zakarya 1dba5d392f fix(android): the tap lands on the drawing, not on the button
Copying a password or a username opened the entry instead - about half the
time, which is the worst kind of failure to diagnose. Since those buttons
carry an icon, the tap target is the <svg> or one of its strokes, so reading
the attribute off the target returned null and the handler fell through to
"open". A finger landing sometimes on the glyph and sometimes on the margin
made it look random. Both handlers now resolve to the button first.

The new-entry form gets a folder, like the editor: an entry filed from the
start saves having to move it.

And a folder can now be created, not only chosen. Filing an entry under a
name is not enough - it is `folders` that fills the desktop's column, and a
name missing from it leaves the entry in a folder nothing displays, looking
lost. The two now go together, in one place, for both editing and creation.
'All' is never registered: it is the default, not a folder.
2026-08-21 22:11:14 +01:00
r-zakarya 0badc5fcfc feat(android): folder and 2FA secret can be changed, and the buttons wear icons
The folder had no row of its own - it shared "Folder / tags" with the tags,
so neither could be touched. It is now CHOSEN from the known folders rather
than typed: a typo would file the entry under a folder nothing displays,
here or on the desktop, and it would look lost. Tags stay read-only because
they are an array in the snapshot, and writing one as a string would break
its shape for the desktop.

The 2FA secret gets its own masked row. The code has had a panel for a long
time, but it is computed and renewed every thirty seconds; the secret is what
one sets, changes and removes, and nothing allowed that from the phone.

User, Pass, Copy, Show and Edit become drawings. Four words on a list row left
little room for the entry's name and had to be re-read every time. Every icon
carries an aria-label: replacing a word with a drawing must not take the
information away from someone listening to a screen reader.
2026-08-21 21:51:42 +01:00
r-zakarya c25d68b721 docs(android): the bench credentials file stays, and why
Decided: it is outside the repository, it no longer enters a release build,
and the token it carries opens a test repository only. Keeping it saves
retyping forty characters at every reinstall of the bench.

What would warrant revocation is a token granting access to anything beyond
that repository - not this one. Recording the decision so it stops coming
back as an open item.
2026-08-21 17:51:11 +01:00
r-zakarya 2fa093d49c feat(android): a vault born here can be given a repository
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.
2026-08-21 17:47:58 +01:00
r-zakarya 0b0f1e3fcd chore(android): section 5 - ten permissions down to three, no secrets in release
The manifest asked for fine and coarse location, camera, phone, phone state
and external storage - Delphi's defaults, none of them used. On a password
vault those are exactly the wrong signals: whoever reads the app's listing saw
a request for position and camera with no possible explanation. The list is
written by hand now.

A trap on the way: naming the substitution marker inside the comment that
explains its removal is enough to bring it back. The substitution is textual
and does not care that it lands in a comment.

The bench credentials are no longer compiled into a release - the local
include is debug-only. Verified in the binary in UTF-8 AND UTF-16: Delphi
stores strings as UTF-16, so an ASCII grep returned zero on both sides and
proved nothing.

The log leaves the home screen in release without leaving the application: a
1.5 s press on the status line brings it back. Nothing secret is in it, but a
permanent Log button is development noise, and diagnostics must stay reachable
for the day something goes wrong remotely.

And the Release configuration did not compile at all, which nobody knew: the
two self-checks had their declarations under IFDEF DEBUG and their bodies
outside. Invisible as long as one only ever builds Debug.

Signing is what remains, and the keystore password is the user's to hold.
2026-08-21 17:38:46 +01:00
r-zakarya eb9999c4b2 docs(android): the conflict path verified end to end, section 4 is closed
Conflict detected, fresh snapshot pulled, the change applied ON TOP of it,
publish accepted. Nothing clobbered - which is the whole point of dropping
the pre-publish fetch.

The second attempt also derived the key again, 4397 ms: the desktop had
re-encrypted its container with a fresh salt, so the key in hand no longer
fitted. That is exactly the case the reuse guards against, seen firing for
the first time. It costs one derivation per desktop publish, no more.
2026-08-21 17:26:24 +01:00
r-zakarya 30b2413ea3 fix(android): a conflict answers 422, not 409
The retry path finally ran, and did not recognise what it was handed. A
desktop publishing between our read and our write earns a 422, not a 409, so
the save failed instead of retrying - which is exactly what a never-exercised
path hides: it was written, it compiled, it was even commented, and it did
not fire.

Gitea answers 409 when the sha no longer matches and 422 when it is missing
or the request is judged unprocessable, "file already exists" among them.
Both say the same thing to us: the sha is stale. Re-reading and retrying is
right for both and cannot clobber anything - the retry publishes against the
fresh sha. A genuinely malformed body would fail again and give up after
three tries, carrying Gitea's own words.

The log now prints which code it was, rather than assuming.
2026-08-21 16:56:48 +01:00
r-zakarya 31bf4f920c docs(android): the write path confirmed from the other end
The desktop, after syncing: pulled 4 new, 1 updated, 1 deleted, pushed 11
entries. Create, edit and delete in one line, seen from the side that did not
perform them. The deletion is the one that mattered - vanishing locally proved
nothing, the tombstone had to travel.

Steps 1 to 3 of section 7 are closed. The 409 is what remains.
2026-08-21 16:49:08 +01:00
r-zakarya 795f07fd8a fix(android): Cancel works, because lastDetail is now written
It was introduced with two readers and no writer. Cancel called
renderDetail(null), which threw on d.rows and did nothing at all - and
editDone, which uses it to hand control back after a refusal, was dead the
same way. That is why the successive fixes for "Cancel does not respond"
changed nothing: they all ended in the same silent throw.

Assigned where a detail is drawn, which is the one place that knows it.
2026-08-21 16:38:26 +01:00
r-zakarya 5522089939 fix(android): the unlock code no longer changes by itself
It went from 0000 to 00000000 with nobody deciding it. The typed code and the
code the cache is sealed under were the same variable, so every write of the
cache re-sealed under whatever sat in the field - a sync, which asks for no
code at all, was enough to change it, silently.

They are two things now. Only opening the vault touches the seal, because
that is the one moment we know the current code is known: the decryption just
proved it. Everything else - sync, background refresh, saving an entry -
rewrites the cache under the code already in place.

Hence a second field, shown only once a code exists: the current one must open
the vault for the new one to take. Without it this fix would have locked the
device under the code set by accident, which is exactly the harm it is meant
to prevent.
2026-08-21 16:30:57 +01:00
r-zakarya 5107ebf7b2 feat(android): an entry can be deleted from the phone
Both, or nothing: the row leaves the snapshot AND a tombstone takes its
place. Removing the row alone does not delete - the desktop still holds its
own copy, and the next pull would bring it back as an entry we never had.
The time matters too: the desktop's arbitration keeps an entry edited AFTER
the deletion rather than killing it a second time.

No trash on the phone, deliberately. The desktop's is a `deleted` flag that
does not travel in sync, so a deletion stopping there would propagate
nowhere. Hence a button at the very bottom, in two taps, that disarms itself
after four seconds - a button left armed gets touched by accident much
later, and this is the one action with no way back.

The confirmation is in the page because the engine cannot offer one: a
JavaScript prompt needs a WebChromeClient FMX does not provide.

Not exercised on the device: it would have destroyed a real entry.
2026-08-21 16:21:45 +01:00
r-zakarya 7a0ae6398b docs(android): the save times as observed in use, not just in one log 2026-08-21 16:16:07 +01:00
r-zakarya 732c52a64e feat(android): every field of an entry can be changed, not just the password
PatchEntryField already took a field name; what was missing was carrying it
from the row. It travels in the detail row itself rather than being guessed
from the label - a caption is prose, and the day it changes the page would
send a field name that does not exist, or quietly stop offering the edit.

Delphi does not take it on trust either: four names accepted, the rest
refused. The snapshot also carries tombstones, attachments and timestamps
that PatchEntryField would overwrite without understanding them.

An empty row now shows IF it can be filled - a missing username you cannot
add is a dead end. Emptying is allowed, except on a password, where it would
almost always be a slip; the refusal says so rather than doing nothing.

Show only appears on a masked field: on a site in clear it has nothing to
reveal, and would in fact hide it. A note keeps only Name and Content - a
site and a username mean nothing there.
2026-08-21 16:13:35 +01:00
r-zakarya 4785dc116a docs(android): note what is left of the save time, and what it would cost
Deferred, with the numbers rather than an impression. The 2.7 seconds left
are entirely the upload, sent twice-encoded because Gitea's contents API
wants base64 inside JSON and the container already is base64. One 116 Ko
attachment is about 78% of what travels on every write.

Three ways out, cheapest first, including the one that needs no code.
2026-08-21 15:57:22 +01:00
r-zakarya 131bf44b36 chore(android): drop the keyboard diagnostic, and the field only jumps if it must
The trace has served: it named the cause where two rounds of reasoning had
missed it. Out with it, and out with the viewport measurement it proved
worthless here - a value that is always zero reads as a mechanism, and the
next person would trust it.

adjustResize goes too. Measured to do nothing: Android ignores the mode for a
fullscreen window, which FMX's is. Leaving it in the manifest would suggest it
carries something.

A field already near the top of the panel - the settings ones - stays where it
is. It was visible as it was, and lifting it only moved under the eye what was
already fine.

The plan keeps what the measurement cost: on this screen, instrument first.
2026-08-21 15:46:06 +01:00
r-zakarya ad8a037e29 fix(android): room under the form while typing, measured by nobody
adjustResize changed nothing either: FMX's window is fullscreen, and Android
ignores the mode there. So three sources were asked what the keyboard covers
- visualViewport, innerHeight, the window itself - and all three answer as if
it were not there.

Stop asking. While a field has focus the panel reserves 60% of the height
below its content; that is overflow, so it scrolls, and the focused field is
lifted to the top of the panel where no keyboard can reach it whatever its
size. The reserve goes at the first field left, so nothing floats when one is
not typing.

Crude on purpose. Every attempt at measuring this correctly has been wrong,
and the honest version of "we cannot know" is to make the answer not matter.
2026-08-21 15:40:58 +01:00
r-zakarya 7ee2feba1b fix(android): the window shrinks for the keyboard, so forms can scroll
Measured, after two fixes placed by reasoning changed nothing:

  focus: inner 719 visual 719 | box client 659 scroll 659 | body=on-detail

Keyboard up, and both heights unchanged: the page has no way to learn the
keyboard exists - visualViewport reports nothing here either. And the box
shows 659 for 659 of content, so there is no overflow and nothing can
scroll. The keyboard simply covers the bottom of a page that believes it
still has the whole screen.

So the remedy was never in the page. windowSoftInputMode="adjustResize"
makes the window itself shrink; the scroll box then gets real overflow and
the engine can bring the focused field into view on its own.

The reserve added below the scroll area stays: it costs nothing when the
window does resize (the measure is then zero), and it is the honest place
for the value should a device ever report it.
2026-08-21 15:32:19 +01:00
r-zakarya 79a6625613 chore(android): make the page say what it measures about the keyboard
Two fixes placed by reasoning changed nothing observable, which means the
reasoning was wrong somewhere I cannot see from here. This writes the
numbers into the log rather than a fleeting message, so they can be read
after the fact: the window height, the visible height, and - the one that
settles it - what the scroll box shows against what it contains. Equal, and
there is nothing to scroll, which would be the whole problem.

Temporary. It comes out once the cause is known.
2026-08-21 15:21:10 +01:00
r-zakarya bde22d2288 fix(android): the keyboard no longer buries the bottom of a form
The frame is nailed to the whole screen, so when the keyboard rises the
content overflows nothing and the bottom third becomes unreachable: the
form's password field could only be seen by folding the keyboard away.

Reserving below the scroll area exactly the height the keyboard covers
gives the panel something to scroll. The measure answers correctly whether
or not the engine actually resizes the window - if it does, innerHeight
shrinks too, the reserve is zero, and the natural overflow already does the
work. No need to know which case applies.

A first attempt shrank the body to the visible height; it changed nothing
observable, and it depended on which of three over-constrained box
properties the engine drops. This does not.

The plan records where a save's time actually goes: 46 ms of crypto, 2745 ms
of PUT for 267 Ko. That is the floor for a monolithic container.
2026-08-21 15:16:12 +01:00
r-zakarya bb222e1903 perf(android): saving no longer pulls the vault it already holds
The crypto is down to about 135 ms, so the wait was never the crypto: the
log says HTTP 200 in 3244 ms for 266299 bytes. Every save fetched the whole
vault, decrypted it, patched it, and sent it back.

The fetch existed to be sure of modifying the latest published version. The
sha proves that better and for free: publish against the snapshot in hand,
and if another device published in between, Gitea refuses with a 409 - then,
and only then, pull for real. The retry loop was already written for exactly
that.

The snapshot kept is the one RECEIVED, never a re-serialisation of TVault:
that would drop everything the phone does not read - attachment bodies, the
avatar, tombstones - which is the invariant PMA.VaultPatch exists for. It
goes at lock, with the rest of the plaintext.

Note this makes the 409 path load-bearing, and it has still never run. It is
now what guarantees a save cannot clobber another device.

The PUT still carries the whole container; that is inherent to the format.
2026-08-21 15:00:46 +01:00
r-zakarya 527a953b2a docs(android): the derivation is measured, 4395 ms down to 48
Confirmed on the device where reading could not: a real save prints
"opened with the key already in hand, in 48 ms". What remains in the wait
is the GET and the PUT, nothing computed.
2026-08-21 14:57:13 +01:00
r-zakarya 9a32fe8881 perf(android): the derived key is kept in the cache
Opening from the cache derives nothing, so the key learned last session was
gone and the FIRST save of every session paid the 4.5 seconds again. It now
travels in the sealed blob, next to the plaintext vault and to the password
that opens the container - that neighbourhood is what makes it acceptable:
it exposes nothing the file does not already hold.

The three parts go together or not at all. A key without its salt does not
say what it fits, which makes it useless, or worse, used where it does not
belong. A cache written by an earlier version carries none: LoadVault
returns nil, one derivation happens, and the next write stores it.

A freshly registered vault still derives once - EncryptContainer does not
hand back the key it just derived.

The plan records what the desktop confirmed: the two entries created on the
phone imported with nothing to correct.
2026-08-21 14:54:05 +01:00
r-zakarya 642dfdb077 perf(android): the key is derived once per session, not once per save
Adding an entry took five to seven seconds. The log now says where they
went: key derived in 4395 ms. Every save re-derived the master password -
600000 iterations - to arrive at exactly the key that had just been used.

The remedy was already half written: EncryptContainerWithKey exists for
this reason, but only within a single save. The key now lives for the
session, and is reused only when the container carries the same salt and
the same iteration count - the desktop may have re-encrypted its own with
a fresh salt, and a key that does not fit fails on GCM's tag while the
message blames the password, which would be right.

Nothing new is exposed: the plaintext vault is already in memory, and the
cache already holds the password that produces this key. It goes with
them, at lock - salt included, or the next open would think it holds a
usable key and decrypt against nothing.

The envelope is read in one place now rather than copied per entry point:
at three copies, a drift between them would surface as a wrong-password
error.

Measured against the real repository: HTTP 200, decrypted, 8 entries - so
the envelope refactor holds. The reuse line itself is not yet measured on
the device.
2026-08-21 14:41:35 +01:00
r-zakarya 078d548110 fix(android): a refusal always hands control back
The form disables its Save button on send, so a refusal that only speaks
leaves it dead with the typed text behind it - which is what happened to
the one guard added with entry creation: name it, press Save, nothing.

The rule had already been forgotten twice before, at the editor. It is a
procedure now rather than two calls side by side, because two calls side
by side is exactly what gets written as one.

Measured on the device: refuse, let the message fade, refuse again - the
second press produces the message, so the button had come back.
2026-08-21 14:29:56 +01:00
r-zakarya d65fe909b4 feat(android): entries can be created on the phone
A vault you cannot add to is not a vault, so an account created here was
useless without this. A plus button next to the padlock, a form, and a
kind - login or note, which the vault distinguishes and whose body is the
password field either way.

One save path now, two transports, and the rule that decides between them
is: if a local container exists it is the authority. A vault born here has
no repository; a vault pulled from one has no local container. The case
where both exist - a local vault that later gains a repository - needs a
merge, and that is section 7 step 4.

The patch arrives as a function rather than an already-patched snapshot:
the remote path must modify WHAT IT HAS JUST PULLED, or it would overwrite
whatever another device published in between. That constraint is why the
two transports could not simply share a finished payload.

Two traps closed while passing: the form left the typed password sitting
in the document after the save, and a refusal left its Save button dead
with the text behind it.

Verified on the device that the button and the form appear and read
correctly. Saving was not exercised - the vault on this phone syncs to a
real repository, and a test entry would have been published to it with no
way to delete it from here yet.
2026-08-21 14:22:08 +01:00
r-zakarya f32cde81fc feat(android): a local account, so the app serves without a repository
Until now the phone could only read a vault that came from elsewhere:
without a URL, a token and a sync password it showed nothing. It now
signs in like the desktop does - username and master password - and can
create its own vault, with no network at all.

The local container carries exactly what the repository would carry: same
format, same encryption under the password. Two reasons, not one. It is
the desktop's model, where the master password protects the vault at rest
rather than the hardware alone; and the format staying the repository's
means sync keeps working with nothing to convert. No Keystore on that
file - it is already encrypted under the password, and binding it to the
device would make it unrecoverable after a reinstall. vault.bin keeps
that role: it is the fast-open cache, and only it needs sealing.

The password opens the container - the decryption itself is the check, no
secret is ever compared - and the username, read from inside once open,
says whose vault it is.

The home screen now knows which of three situations it is in: nothing
here yet, a container without a cache, or a cache ready to open.

Verified on the device: with the cache set aside, the screen offers
username, master password and Create vault, alongside the download path.
Creating an account could not be exercised to the end - the device locked
its screen mid-run - and the vault that was set aside is back in place,
same size, still sealed under its code.
2026-08-21 14:07:39 +01:00
r-zakarya b1be802e22 docs(android): section 7 - a local account, so the app serves on its own 2026-08-21 13:55:13 +01:00
r-zakarya afc6119cf8 fix(android): the unlock code is asked for, and never undone in silence
Two defects, one feature made useless. The cache did seal under the code,
but nothing on the home screen asked for it: opening without one failed,
the failure was read as corruption, and the device offered to download a
vault it held perfectly well. And the next refresh rewrote the cache in
the old shape, so the code was undone seconds after being set, silently.

The code now lives on the home screen, not among the server settings: it
seals this device's cache, while the other three fields describe the
shared remote. Always visible - hiding it when none is set would remove
the only place to set one - the hint saying which case you are in. An
absent code is no longer attempted, a wrong one is no longer corruption,
and a sealed cache is never rewritten without its code.

The two panels each got an arrow, and only one opens at a time.

Measured end to end on the device: version byte 1, code typed, open, byte
4; cold start says "Unlock code required" and open without the code
leaves the byte at 4; correct code opens the vault, and the refresh that
follows leaves it at 4.
2026-08-21 13:04:48 +01:00
r-zakarya b7125a7373 docs(android): section 4 - the interface, set straight 2026-08-21 12:49:48 +01:00
r-zakarya 8f8a8ec3f4 fix(android): the back key folds the keyboard before anything else
It was our own handler. The web view is not a field the system knows, so
it does not intercept the key for us - it arrives here, and we consumed
it in every case: the vault locked, or the list came back, while the
keyboard stayed up with no way left to close it.

hideSoftInputFromWindow answers whether there was anything to fold, which
is the synchronous signal needed to decide. Something folded, key eaten;
nothing folded, the key keeps its usual meaning.

Measured on the device, before and after: mInputShown stayed true, it now
goes false and the application stays in front. A second press locks, as
before.
2026-08-21 12:48:29 +01:00
r-zakarya 90ea09a718 fix(android): the keyboard closes, and the write test is gone
Same origin as the sideways slide. The keyboard's validate key asks for
the next field, and the next one in the document sits on a view parked
outside the frame. Focus landed there unseen: the keyboard changed shape
- numeric for the code, alphabetic for the search - and would not close,
because the field holding it open was off screen.

No field of a resting view keeps the focus now, and the validate key
closes the keyboard instead of jumping. In the editor it saves, which is
what the key is for after typing a password.

The write test goes with it: it proved the write path before the first
real PUT, that path now carries the vault itself, and the button, its
handler, the probe and the debug hook that revealed it were only ever
scaffolding.
2026-08-21 12:39:22 +01:00
r-zakarya 7a9fe4ae77 fix(android): nothing slides sideways any more
The previous attempt clamped the scrolling panes, and the panel still
slid. It was not the panes: overflow:hidden forbids the GESTURE, not the
engine, which keeps the right to move a box so the newly focused field
comes into view - and the rail's off-screen views, parked at
translateX(100%), leave it room to the right. Confirmed by measurement:
the CSS was in the shipped library and the behaviour was unchanged.

Caught in the capture phase now, whichever box moves and whatever moves
it. No view here has content to the right, except the folder strip.
2026-08-21 12:34:09 +01:00
r-zakarya 75fde64c20 fix(android): the detail panel can no longer be dragged sideways
The editing row kept the full-width field and set three buttons beside
it, so the line was wider than the frame. A pane scrollable on one axis
is scrollable on both - the specification turns the untouched axis into
`auto` - so the overflow was draggable, and the keyboard's Enter key
brought the field back into view, taking the whole panel with it.

The field now shares its line, and no pane scrolls sideways. Enter
validates, which is what the key was pressed for.
2026-08-21 12:30:42 +01:00
r-zakarya 3392bf7c7a fix(android): the unlock code re-seals the cache when the vault opens
Setting a code and syncing did nothing, and the log said why: "304: unchanged,
vault served from cache". A sync only writes when the remote actually changed,
and it is the write that re-seals - so vault.bin kept its old format and its
old date, and the code looked inert. My instruction to type the code and sync
was wrong; I gave it without following the path.

Re-sealing belongs where the plaintext is already in hand: opening the vault.
That spot has a precedent - the existing path that re-seals under
authentication when a screen lock reappears - and the code now takes priority
over it, since the two protections do not stack.

The log also names the format it opened, so "sealed under the unlock code" can
be read rather than inferred from the absence of a warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:21:18 +01:00
r-zakarya a41eeb8bfa feat(android): an unlock code that survives a screen-lock change
The reported pain: changing the pattern destroys the local cache every time.
That is Android doing exactly what it promises - a key created with
setUserAuthenticationRequired is invalidated the moment the screen lock
changes, so a new lock cannot open data sealed under the old one. No storage
format changes that, which is why SQLite would not have helped: the problem is
what the key is tied to, not what the file looks like.

So the cache gains a third sealing format, VER_PIN, with two layers:

  inner - AES-GCM under PBKDF2(code, 100k) : it takes the code
  outer - the Keystore AES key WITHOUT the auth constraint : it takes the device

The outer key is never invalidated, so the file survives any change to the
screen lock; the code restores the protection that removing the constraint
would otherwise cost. 100k iterations rather than 600k because a four to six
digit code carries little entropy - more rounds mostly slow the honest user,
and the real barrier is the outer layer requiring this phone.

The code lives in a fourth settings field and is NEVER stored: empty at every
launch, typed to open, and used to re-seal on every write. That is what
separates it from the token, whose persistence is a convenience with no
consequence since it only opens ciphertext.

Built but NOT installed - the phone went off the network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:10:51 +01:00
r-zakarya 3e020acf24 docs(android): section 4 verified on device
save: attempt 1 - GET
    save: decrypt+patch in 4519 ms
    save: re-encrypt in 23 ms
    save: published, new sha 802515d4...

The phone writes to the vault. The 23 ms measure the reused key: the second
derivation was worth the same 4.5 seconds as the first.

Three things are recorded as open rather than left to be rediscovered: the 409
retry is written but has never run, there is no offline editing (the phone
patches the snapshot it just pulled, so Save needs the network - that is the
price of having no merge to write), and only one field is editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:35:57 +01:00
r-zakarya 7528146a4e fix(android): the save actually reuses the derived key
The log named it exactly: "Cle ou sel absent pour le rechiffrement". The edit
that swapped DecryptContainer for DecryptContainerEx had never been applied -
the timing line from the same change was missing too, which is what should have
told me. So the key was never collected and re-encryption refused.

Worth keeping in mind: the guard in EncryptContainerWithKey did its job. It
refused rather than encrypting under an empty key and writing a container
nobody could read into the vault. A failed save is an inconvenience; a
published unreadable snapshot would have been the kind of loss this whole
section is built to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:32:26 +01:00
r-zakarya 587536bb68 fix(android): every refusal hands the screen back
Reported together, and the second explains the first: Cancel did nothing
because Save disables it and nothing re-enabled it - a disabled button fires no
click. And the screen never came back because the entry guards (busy, no uuid,
no sync password) returned with a toast and nothing else, leaving the editor
open with every control dead. A cul-de-sac with no way out but killing the app.

Each guard now calls PM.editDone, which redraws the row from the last detail
received. The page can leave edit mode without asking Delphi for anything,
which is what it needed to recover from a refusal that never reached the
network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:27:17 +01:00
r-zakarya 4d9fd2c34a fix(android): a failed save can no longer hang on "Saving..."
The except only covered decryption and the patch. An exception from
re-encryption or from the PUT skipped the block that restores the screen - and
that block also held the message, so the failure was invisible as well as
permanent: the row stayed on "Saving..." with nothing to explain it.

Restoring the screen now lives in the finally, so it happens whatever the code
above did. A catch-all around the body turns any unforeseen exception into a
message and a log line, because a failure that says nothing is indistinguishable
from slowness - which is exactly how this one was reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:01:18 +01:00
r-zakarya b9a3b3e748 fix(android): saving now answers, and the editor hides what you type
Two defects, both reported from the device.

Nothing came back after Save. AdoptVault rebuilds the LIST, not the detail, so
the row stayed in edit mode: the input remained on screen and nothing said
whether the write had gone through. The detail is now redrawn from the vault in
both cases - after a success it carries the new value, after a failure the old
one, which reads without needing a sentence. It is found by UUID rather than by
index: the vault has just been replaced by the server's, and nothing guarantees
the same entry sits at the same rank.

The button also stayed inert-looking for the several seconds a save takes, so it
now says "Saving..." and disables itself while the work runs.

And the typed password showed in clear. My reasoning was that you cannot verify
it anywhere else - but that argues for a reveal button, not for leaving it
exposed the whole time. It is masked, with Show next to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:54:46 +01:00
r-zakarya f245cdcc38 perf(android): one key derivation per save, not two
Reported as "Save takes 6 to 8 seconds". A save decrypts the snapshot it just
pulled and re-encrypts the patched one - with the same password. That was two
full PBKDF2 runs at 600k iterations, four to six seconds each on this phone,
for two keys that protect the same file.

DecryptContainerEx now hands back the key, its salt and the iteration count,
and EncryptContainerWithKey reuses them. The IV is still drawn fresh every
time: reusing an IV with the same key breaks GCM entirely, whereas reusing the
salt merely yields the same key - which is the whole point. A salt slows an
attack on the password; it has no reason to differ between two versions of the
same file.

EncryptContainer keeps its old shape and now delegates, so there is a single
place that composes a container.

The log times both halves, so the next measurement says where the remaining
seconds are rather than inviting another guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:50:18 +01:00
r-zakarya 440bfd5536 feat(android): change a password from the phone, section 4 complete
Pull, patch, re-encrypt, publish - in that order, and against the SERVER's
snapshot rather than the local cache. That is what replaces the merge the plan
asked for: anything untouched is already everyone's newest version, so there is
nothing to reconcile.

The only conflict left is another device publishing between our read and our
write. Gitea refuses it on the sha with a 409, and the whole cycle repeats,
decryption included, since the content changed. Three attempts: past that the
other device is publishing in a loop and insisting would not help.

The editable field is marked in Delphi rather than recognised by its caption in
the page. A caption is prose; the day it changes, matching on it would silently
stop offering the edit.

The editor is inline rather than window.prompt: a JavaScript prompt needs a
WebChromeClient that FMX does not provide, and prompt() then returns null
without a word - the button would appear to do nothing. The value shows in
clear while typing, deliberately: you are entering a password you cannot read
back anywhere else, and masking it would mean confirming a keystroke blind.

This is the one command that carries a secret over the bridge, agreed
beforehand. It stays in the process, the iframe carrying it is destroyed, and
the handler logs the action only - never the query.

Only the password is editable. Creating or deleting an entry would need
tombstones and uuid generation, and changing a password covers the need
without either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:41:05 +01:00
r-zakarya 6bb056220e feat(android): patch the snapshot in place, never rebuild it
Section 4 step 4. TVault reads a subset of the fields, so re-serialising the
vault from what it understood would drop attachment contents, created_at, the
account avatar, tombstones, and whatever the desktop adds next - and the
desktop would adopt that amputated version on its next pull, the merge being
last-write-wins on updated_at. Silent, and final.

So nothing is rebuilt: the received JSON is edited in place and the rest is
handed back untouched. What the phone does not understand travels intact by
construction rather than by vigilance.

A corollary worth naming, because it removes the step the plan feared most:
since the patch applies to the snapshot just pulled, there is no merge to
write. Anything untouched is already the server newest version. The only
possible conflict is another device publishing between our read and our write,
which Gitea refuses on the sha.

The self-check tests what is PRESERVED, not what changes: a field unknown to
TVault, an attachment content_b64, the untouched sibling entry, the
tombstones. It reports "patch keeps what it does not understand" on the device
at every debug launch.

Also confirmed by this log: the write token now survives a restart, so the 304
settings fix holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:35:12 +01:00
r-zakarya 5324785ed9 fix(android): the token field is named for what it is, not for one server
It said "gitea token", which is wrong for the other transport this client
speaks: the same field carries a Bearer token to the PHP endpoint. The URL
decides which - naming the field after one of them makes it false for the
other.

Worth stating plainly while here: this client does NOT do WebDAV. There is no
Basic auth in PMA.SyncClient and no username field; only the desktop still
speaks it. Supporting it here would need both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:25:51 +01:00
r-zakarya 060611f101 fix(android): drop the labels, put a clear cross in each field
The labels went in this afternoon to tell the token and the sync password
apart, both being masked and stacked. They are removed on request; the
placeholders carry that job again, which they do while the fields are empty -
and that is when it matters, since a filled field is one you just typed.

Each field gets a cross instead. Clearing a forty-character token with the
backspace key takes forty presses and leaves a character behind easily, which
shows up as a 401 whose cause is invisible.

The cross is a SIBLING of the input, laid over it - never a child. A child of
an input does not receive the tap; that cost a build earlier today when the
search field was given its own cross.

It also pushes the configuration immediately: the field keeps the focus, so the
blur that normally carries the value to Delphi never happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:18:39 +01:00
r-zakarya 382e38913b feat(android): the write path works, and settings survive a 304
Proven on device, against the real repository:

    GET first: HTTP 200, sha 8feb3b7d...
    PUT .../write-test.json
      OK 200 - new sha 67d6a4b2...

Read the sha, write with it, receive the next one. Section 4 step 3 is done.
The probe reads before writing because that is the real sequence: an empty sha
means "create", and Gitea answers 422 when the file is already there - which is
what it did, and rightly.

The second fix comes from an interaction between two changes made hours apart
today, and only visible once both were in place. Settings are stored on the
line after "received N bytes"; but now that the sha comparison works, a sync
answers 304 and returns before ever reaching it. So a freshly typed token was
never saved and vanished on close - which is exactly what the user reported.

They are now kept as soon as the transport answered at all, 200 or 304: both
prove the address and the token, only the body differs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:05:28 +01:00
r-zakarya 683655415f fix(android): locking the vault no longer discards the typed settings
Reported as "the token field saves nothing", and it was exact: LockVault
called PushConfig, which pushes the STORED server settings back into the page.
Every lock therefore overwrote a freshly typed token with the old one -
including the three-minute auto-lock, so it happened with no gesture from the
user at all, and the field looked like it forgot on its own.

The user's own sequence isolated it: deleting one character gave a 401, which
proves the typed value does reach the wire; then open, lock, sync gave a 200
again, the lock having restored the old token in between.

PushConfig now runs at startup only. It was pointless in the other four places
anyway: PM.wipe clears the list and the detail, never the configuration fields.

Along the way, and part of the same confusion: the three settings fields had no
labels, only placeholders that vanish once filled - and since the token was
masked this morning it became indistinguishable from the sync password sitting
right below it. They are labelled now, and the log reports both fingerprints so
a value landing in the neighbouring field shows up immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 18:52:45 +01:00
r-zakarya 90e8fabf58 feat(android): the write transport, and an error that says why
TSyncClient.Put publishes a container to the Gitea contents API: token header,
JSON envelope with the file base64 inside, sha omitted rather than empty when
creating, branch omitted so the repo default is used. 409 becomes its own
status, ssConflict, because it means re-read and re-apply rather than retry.

The first PUT is where a mistake costs data, so the probe writes to
write-test.json rather than the vault. Same repository, same token, same
envelope, same concurrency check - and the vault is never at stake. Reachable
from a debug-only button; the page asks Delphi whether to show it.

Two mistakes of mine on the way, both about replacing information with a guess:

- the probe did not push the config first, unlike Sync, so a token that had
  just been pasted had not reached Delphi and the 403 blamed the wrong thing.
- 401 and 403 were rewritten as "token lacks write access", discarding Gitea's
  body. That is an interpretation, not an observation: the same code covers a
  read-only repository, a protected branch, a token from another account. The
  body says which - here, plainly, "token scope=read:repository".

Also recorded: the sha comparison now short-circuits (304 where it used to
decrypt), so the difference was case or whitespace, absorbed by SameText on
trimmed values. The offending character itself was never seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:47:55 +01:00
r-zakarya ae48fadb6b feat(android): encryption, verified by a third implementation
Section 4 step 2. EncryptContainer produces the exact container the desktop
writes: PBKDF2-SHA256, AES-256-GCM with the tag appended as WebCrypto does, a
32-byte salt and a 12-byte IV drawn per call from SecureRandom on Android and
BCryptGenRandom on Windows - never Random, since a predictable salt or IV ruins
the encryption that uses it and nothing in the output would show it.

The check matters as much as the code. A round trip with itself only proves
internal consistency, and it passed while the container carried two defects.
Node - the same primitives as WebCrypto - found both by reading a container the
phone had produced:

- Delphi's base64 wraps at 76 characters; WebCrypto's never does. Lenient
  decoders accept it, but depending on their leniency is not a guarantee.
- the accented literal in the check came out of the COMPILER already broken:
  this source has no byte-order mark, so Delphi reads it as ANSI. The defect
  was in the test rather than the encryption, and only the cross-check could
  tell those apart.

The check runs at launch in DEBUG and leaves selfcheck.json in the private
directory so it can be re-read from the PC.

Also fixed on the way: TPath resolves to FMX.Objects.TPath - a drawing shape -
because FMX.Objects comes later in the uses clause, and the error then talks
about an unknown Combine rather than a name collision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:08:34 +01:00
r-zakarya c73ac66313 fix(android): the token is stored, not displayed
Kept in settings.bin, because the silent refresh needs it and so does the
first download on a fresh device - there is no vault to hide it in yet. But it
was the only secret on that screen shown in clear, right beside a masked
password field, and it is also the least protected of the three: settings.bin
is sealed by the no-auth Keystore key, readable without authentication, unlike
the vault.

Masked now. It is typed once, and reading it back has no use - a wrong token
shows up as a 401 in the log, not by squinting at the field.

What it opens stays worth knowing: read access to the account's repositories,
whose only content is ciphertext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:06 +01:00
r-zakarya 60e3cae3e4 docs(android): record the sha comparison as open, not solved
Verified on device: opening the vault no longer calls the server on every
open. The throttle works.

What it does not do is explain the underlying defect, so the plan now carries
it as an open item with the measurement, what has been ruled out (the Gitea
transport is selected, SaveVault stores what it is given, vault.bin is
rewritten, the container decrypts), and the distinction between worked around
and fixed. Two fingerprints print identically and compare unequal; only an
invisible character explains that, and reading the path did not find it.

Written down rather than left in a commit message, because the cost is now
paid once a quarter of an hour instead of on every open - which is exactly the
kind of defect that stops being noticed before it stops existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:18:30 +01:00
r-zakarya 65f5659d06 fix(android): opening the vault no longer calls the server every time
Asked plainly by the user: why does open trigger a sync? Because
OpenLocalNow displays the cache and then refreshes in the background, so a
change made on the desktop shows up without anyone thinking about it. Sound
intent, applied on EVERY open - including a lock followed by a re-open three
seconds later, which cannot learn anything new. Measured cost of one such
pointless refresh: 1 s of download and 5.5 s of key derivation.

A minimum interval between two background refreshes fixes that, whatever else
is wrong: the desktop does not publish several times a minute, and looking at
most once a quarter of an hour is enough for a change to arrive unnoticed.
Pressing Sync goes through another path and never waits.

The sha comparison that should already have made an unchanged refresh cheap is
still not firing - two fingerprints print identically yet compare unequal. It
is now compared with SameText on trimmed values, which cannot confuse two
distinct snapshots since a hex digest never differs by case or whitespace
alone, and the log reports both lengths so the invisible character shows up on
the next real refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:15:07 +01:00
r-zakarya f97e44768a fix(desktop): stop pushing a snapshot that changed nothing
Measured on the phone, opening a cached vault: 121 ms to read the sealed file,
14 ms to display it - and then 5718 ms decrypting a snapshot it already had.

The cause is upstream. A sync pushes unconditionally at the end, and the push
is never a no-op: the container is re-encrypted with a fresh salt and IV, so an
identical vault produces completely different bytes. On Gitea that is one more
commit and a new blob sha, so every other device sees a "new" snapshot and pays
a full key derivation to arrive back where it started. It also grew the
repository on every sync that changed nothing, which is the history cost the
plan warns about.

The merged state is now compared against what was pulled, as plaintext -
the only place equality is observable - ignoring snapshot_at, which changes by
construction. Equal means the push is skipped and the sync still counts as done,
timestamp included: having nothing to write is a success, not an absence.

The comparison is deliberately strict. A false negative costs one commit; a
false positive would silence a push that carried a change and lose it for the
other devices. Four tests fix where that line sits, including a modified
updated_at (which arbitrates the merge elsewhere) and a tombstone (a deletion
that has to propagate).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:41:44 +01:00
r-zakarya aa87580443 fix(android): no disc behind an entry icon
The translucent circle exists to back the initials of an entry without an
icon. A favicon brings its own transparency, so the circle showed through and
around it - a saucer under the drawing, plainly visible on the Gitea cup.

The circle is dropped as soon as the row carries an image, through a class on
the avatar rather than :has() so it does not depend on how new the engine is.
object-fit also goes from cover to contain: an icon is not a photograph and
has no business being cropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:28:48 +01:00
r-zakarya 930f74147f docs: section 6 is done - both ends sync through Gitea
The full round trip ran: the desktop pushes (second commit in pm_sync), the
phone reads what it published. The proof was one entry - "gitea token read",
created on the desktop, appeared on the phone without touching sync.php or
WebDAV, and the trash did not travel, which is what buildSyncSnapshot intends.

Recorded with it are the three details that would cost an evening to
rediscover: sha omitted rather than empty on a first push, branch omitted so
the repo default is used, and the desktop token needing write:repository while
the phone keeps read.

Also recorded, though unrelated to sync: settings sections are mapped to tabs
by their visible label text, so renaming one moves the section to the wrong tab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:20:11 +01:00
r-zakarya eac3cac9d6 fix(desktop): the Sync section reappears in its own tab
Renaming the label from "Sync (WebDAV)" to "Sync (WebDAV or Gitea)" hid the
whole section from the Sync & Backup tab: applySettingsTab maps sections to
tabs BY THEIR VISIBLE LABEL TEXT, so the miss fell through to 'general'. The
section was not lost - it moved to the General tab, which is worse than
missing, because it is nowhere you would look for it.

The map is updated, and the coupling is now written down where the map lives:
these keys are the displayed strings, word for word, and rewording one means
touching both sides.

Checked every key against the labels in index.html; the only sections without
an entry are "Getting started" and "Startup & tray", which belong in General
anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 15:01:31 +01:00
r-zakarya c1d0eb3891 feat(desktop): sync to a private Gitea repo, chosen by the URL
Same discriminant as the Android client - "/api/v1/repos/" in the URL - so one
address tells both ends which transport to speak, and nothing new appears in
Settings beyond a placeholder and a warning.

The bridge stays shared. Three things differ, and they are confined: the header
is "token <PAT>" rather than Basic (Gitea answers a silent 401 to the other),
a PUT carries a JSON envelope with the file base64-encoded inside it, and the
connection test uses GET because the contents API does not answer HEAD.

The sha does the ETag's whole job. It comes back from the pull, rides in the
PUT body instead of an If-Match, and a stale one earns a 409 - handled exactly
like WebDAV's 412, by re-running pull->merge->push so the other device's
changes are folded in rather than clobbered. On a first push `sha` is OMITTED
rather than sent empty: Gitea reads its presence as "replace that exact
version". `branch` is omitted too, so the repo's default is used instead of a
name we would be guessing.

A failing PUT now reads its response body: Gitea explains a 409 or a 422 there,
and without it the error was just a number.

The test button checks the SHAPE of what came back, not only the status. A URL
one path segment off answers 200 with valid JSON that is not a file - which
would otherwise surface much later as "wrong sync password" and send you
looking on the wrong side. Four tests cover that parsing, including the
line-wrapped base64 Gitea sends and the array an accidental directory URL
returns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:37:39 +01:00
r-zakarya 6cab9b7c0a feat(android): Gitea read path verified on device
Against Zaki/pm_sync on Gitea 1.26.1, container uploaded through the web UI:
HTTP 200 in 1079 ms, decrypted, cache written with the sha; the next Sync finds
the same sha and skips the derivation entirely. That was the point of the ETag
on the PHP endpoint and it survives the change of transport.

One log line was lying: it announced If-None-Match, which is never sent to
Gitea - the API does not know that header, and the comparison happens on the
sha after reception. Announcing it sent you looking for a 304 from the server
that will never come. It now says "known sha" on that transport.

The plan records the two frictions met on the way, both already fixed: settings
kept only after a successful decryption (so a wrong password lost the URL and
the token every time), and the launch refresh being silent and using the
password stored with the vault rather than the field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:09:36 +01:00
r-zakarya c2f7f47eba chore(android): the Gitea contents URL becomes the first-run value
The saved settings win over the first-run constants, so a typed URL that never
survived a successful transport kept losing to the old PHP endpoint. The
device's settings.bin was removed (the vault cache was NOT - it is the only
offline copy on that phone, and wiping it is not needed to change an address).

The Android constant now points at the contents API rather than the repo URL:
it is the one that returns the file with its sha, and the client recognises
"/api/v1/repos/" to pick that transport. Windows keeps the local endpoint - the
machine hosts it, and nothing requires it to go out on the network to read
itself.

The token stays out of the repository, as does the sync password: both live in
the gitignored PMA.LocalConfig.inc, or get typed on the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:00:59 +01:00
r-zakarya 661aac744c fix(android): keep the endpoint and token once the transport works
They were saved only after a successful decryption, in the name of storing
proven settings rather than typed ones. The consequence showed up the moment
the password did not match: the URL and the token were lost on the next
launch, and both had to be typed again - an API URL and forty characters of
token, on a phone, on every attempt.

A 200 with a container in hand already proves the address and the token. The
password is a separate matter, and this call never stored it anyway. So the
settings are kept there, before decryption, and the vault plus its fingerprint
still wait for a decryption that actually worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:48:54 +01:00
r-zakarya 74b20266b3 fix(android): a missing reply is not "HTTP 0"
The log printed "HTTP 0 in 2013 ms" when no HTTP response arrived at all.
Zero is not a status code, and written that way it reads like one the server
returned - which sends you looking for the cause on the wrong side of the
connection. It now says "no HTTP reply after ... ms".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:40:53 +01:00
r-zakarya c215a5755d fix(android): a network call that never returns no longer kills the interface
Reported symptom, with the local server stopped: Sync leaves the app inert -
neither Open vault nor Sync responds, and nothing is displayed. Log and
Server settings still work, because they are local to the page.

The log proves where it stops: the GET line is there, the "HTTP ... in ... ms"
line that immediately follows the call is not. So the request never returned,
SetBusy(False) never ran, and the two actions stayed disabled - a disabled
button fires no click, hence the silence. THTTPClient is given 15 s timeouts
and the Android request does inherit them, so this should not happen; rather
than keep chasing why it did, the situation is now impossible.

A deadline timer, armed while a request is in flight and longer than the HTTP
timeouts so the precise network error still wins when it works. On expiry the
interface comes back and says so. The thread is abandoned, not cancelled: it
dies when its socket fails, and if it somehow succeeds its result still shows.

The page's bridge queue had the same shape of bug: a throw while removing the
iframe left "sending" true forever, and every later command was dropped in
silence. The removal is now in a try/finally.

Also fixed while in there: SetBusy hardcoded "Sync" as the button label, so a
vault-less device lost "Download vault" on every busy transition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 13:11:21 +01:00
r-zakarya 0bcf9b95a2 docs(android): a Gitea token cannot be limited to one repo
The plan said to create a personal token "limited to this repo". Gitea has
no such thing - checked against 1.26.1: its scopes are per category and
account-wide, so read:repository grants read on every repo the account can
read. That is not GitHub's fine-grained model, and the difference changes
what you actually do.

So the section now says what is available instead, cheapest first:
read:repository alone while the phone only reads, a separate token on the PC
when publishing lands, and a dedicated Gitea account as collaborator if real
isolation is ever needed.

Also recorded: the contents endpoint answers 404 unauthenticated on a
private repo, which is the same snapshot-absent-or-no-access ambiguity the
PHP endpoint has, and the client already treats it as "nothing to download".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:42:33 +01:00
r-zakarya 68df2dd7e7 feat(android): read the snapshot from a Gitea repo, and close cleartext
PMA.SyncClient now speaks two transports, told apart by the URL:
/api/v1/repos/ means the Gitea contents API, anything else the old PHP
endpoint. The PHP path stays until the Windows side publishes to the repo
too - without it the migration would need both ends flipped at once, with
no way back.

Three things differ on the Gitea side and nothing else: the header is
"token <PAT>" not "Bearer" (swapping them gives a silent 401), the body is
JSON carrying base64 content, and the fingerprint comparison happens in the
client since the API has no If-None-Match. The body is re-downloaded, which
is cheap; the four-second key derivation is still avoided. The result's
ETag field carries the sha, so the caller cannot tell which transport
answered.

usesCleartextTraffic is replaced by a network config that permits cleartext
to 127.0.0.1 and localhost BY NAME and forbids it everywhere else. The
attribute drew no such line: a mistyped URL sent the bearer token over the
network in the clear with nothing to signal it. Now it fails immediately,
which is the behaviour worth having.

Verified in the built APK: res/xml/network_security_config.xml is present
and the manifest attribute resolves to a real resource id. NOT verified
against a real repo - that needs a private repo, a published
vault-sync.json and a token, none of which exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 11:55:24 +01:00
r-zakarya b3fbaf44bd docs(android): the plan catches up with the HTML interface
Section 3 bis asked whether Delphi controls were needed at all and said to decide before investing further in the interface. It is decided, so it now records the answer and the two traps buried in the bridge - the iframe, and the page having to stay ASCII - rather than leaving them to be re-discovered.

Section 1 ter described Skia as the fix for Arabic; it is gone, and what survives of that episode is the packaging constraint, which still governs the build.

The table in section 0 claimed template, icons, attachments, timestamps and folders were unread. They are read now; it says which parts, and what is genuinely still missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:53:02 +01:00
r-zakarya 77809b09c8 refactor(android): the unlock screen moves to HTML, Skia and the style go
The caret in an Android TEdit does not land where you touch, which makes a long bearer token impossible to correct - the reason the last FMX screen had to go. A field in the engine inherits the system's text handling (caret, selection, handles, paste) for free.

With no FMX control left, two things became dead weight and are gone: the vendored Dark.style, which had nothing left to style, and Skia - 25 MB carried solely so FMX labels would shape Arabic. The engine does its own shaping; checked on the device, an Arabic filename still reads correctly. The APK drops from 22.0 to 12.7 MB.

Delphi stays the owner of the server settings; the page only edits them and hands them back when a field loses focus, one navigation per changed field rather than one per keystroke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:40:06 +01:00
r-zakarya d1d5d29879 fix(android): the page stops sliding, and locking comes back
A swipe with nothing to scroll dragged the whole document, showing the body gradient sliding under the screen gradient - two gradients stacked and no content at all. overflow:hidden does not prevent that in an Android WebView; position:fixed does.

The lock button went missing when the list moved to HTML: it lived in the old FMX bottom bar and had no counterpart in the page. It returns as a padlock next to the search field, no label - it is the one icon universal enough to stand alone.

Icons on the unlock screen too, as code points since this source is read as ANSI. All four are basic-plane and common, so a font without them would show an empty box rather than a wrong glyph; checked on the device, they render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:18:33 +01:00
r-zakarya ec8b5b3410 feat(android): a shield instead of a padlock, and the launch screen matches
The padlock was drawn in thin blue strokes on slate: at 36 px the outline muddied and the shackle vanished. A filled white shield with a punched keyhole keeps its silhouette at every size, and says protection more plainly.

The keyhole is CUT OUT rather than painted, through an even-odd fill on a single path - painting it meant keeping two files agreeing on one colour, and any drift would have read as a smudge. The launch layer is the exception: it sits flat on the system background, where a cut-out would be a hole, so there the keyhole is painted and stays entirely inside the white shield.

The launch background takes the top of the app gradient instead of slate, so the launch screen dissolves into the first screen rather than cutting against it. No gradient inside the vectors: the aapt this chain uses does not know inline gradients.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:02:27 +01:00
r-zakarya 8c1cabb5bd feat(android): the list and the detail move to HTML
The FMX controls lost this one on merit. Every screen re-taught the same lessons: a template missing from the loaded style, an alignment order left to chance, a HitTest swallowing a tap, a native view painting over everything. The engine draws the same interface in forty lines of CSS and shapes Arabic on its own.

The bridge needs no local server, so no port is open on the device: evaluateJavascript carries the data down, an intercepted cmd:// navigation carries the actions up. Through a throwaway iframe - FMX always tells the engine it did not handle the navigation, which on an unknown scheme replaces the page with an error screen.

Secrets do not travel over that bridge. The list gets names, sites and usernames, which are already on screen; copying from a row sends an index and Delphi reads the vault itself. Only the open entry has its values in the JS heap, and PM.wipe clears them on lock.

Folders become a row of chips with counts instead of a full-screen panel: one tap, and the count says what to expect.

Also gone: about eight hundred lines of the screen this replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:57:46 +01:00
r-zakarya 470598a723 fix(android): no User button on an entry without a username
A note has no identifier - the button copied nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:22:58 +01:00
r-zakarya 7218df2f6f feat(android): copy from the list, server params grouped, HTML probe
The card no longer swallows the tap: it covered the whole row and OnItemClick never fired. It paints, nothing more - HitTest false - and its children keep theirs. The two buttons take a Left and a Right rather than fixed coordinates, which put them outside the frame.

The sync password joins the URL and the token: it is a server parameter, and it filled the home screen although it is typed once. The panel opens by itself when one of the three is missing - folding away empty fields hides the only thing left to do.

And an HTML probe, to answer a question rather than argue it: the same list painted by the system engine. It renders well, Arabic included. Two lessons: FMX passes a nil encoding to loadDataWithBaseURL, and this source file carries no byte-order mark, so Delphi reads it as ANSI and an Arabic literal is already broken at compile time - hence the numeric references. Neither is a weakness of the engine, which is what I first suspected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:16:18 +01:00
r-zakarya e85f46f501 feat(android): copy from the list, framed entries, one radius everywhere
The cross moves inside the search field, where it belonged. My first attempt failed with a TLabel, whose HitTest is false by default, and I concluded too quickly that no child of a TEdit can take a touch. A TRectangle takes it.

Buttons drop the pill and take the fields' radius: a stadium beside a 14-radius field put two shape vocabularies on one line, and consistency is worth more here than fidelity to the reference.

List entries get their own block, like the detail fields - on a gradient a bare line of text has no readable edge. And each row carries User and Pass buttons, so the commonest gesture no longer requires opening the entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:44:52 +01:00
r-zakarya 218a0dde03 fix(android): the search field stops moving, and the magnifier steps aside
Showing and hiding the clear button changed the field's width on every keystroke, so the whole row jumped. Its slot is now reserved permanently and only the cross itself appears - the field keeps one width.

The magnifier also fades once typing starts: it was there to say what the field is, and once there is a query it tells you nothing you cannot see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:35:30 +01:00
r-zakarya d22089900c fix(android): the edit fields were still shaped like pills
A radius of 24 kept them fully rounded even after the fill was hollowed out, so a text field still read as a command. They now take 14, the same as the row buttons - rounded, unmistakably rectangular.

The search field loses the stadium shape of Android's global search along with the others, since one style serves every edit. Its magnifier is what identifies it now. Giving it back its own shape means cloning the style entry, which is worth doing only if the shape is missed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +01:00
r-zakarya 4347dba8d9 feat(android): the system bars join the gradient
The app sat in a frame that did not belong to it: a grey status bar above, a WHITE navigation bar below, under a blue background. The reference bathes both in the same gradient, and that is what makes an app look like it occupies the screen rather than being placed on it.

Each bar takes one end of the gradient - top colour above, bottom colour below - so the continuity is exact. The FLAG_SECURE condition moved from the uses clause into the body of SecureScreen, since the same JNI imports now serve the bars, which must work in debug too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:23:27 +01:00
r-zakarya 9bc005696d fix(android): a field should look recessed, a button raised
They carried the same fill and the same pill, so the password field looked like a command. A field now holds 10 per cent white against a button's 18 and a main action's 30 - hollow against raised, which is the only cue left once borders are gone.

The settings panel also reclaimed the top of the screen, above the title: created hidden, FMX moves it to the head of the stack when it becomes visible. Its position is now reasserted each time it appears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:18:16 +01:00
r-zakarya 2e2ded1146 fix(android): the Copy buttons were ellipses, not pills
A fixed radius of 26 on a button only 40 tall makes the corners meet, and the shape stops being a rounded rectangle at all. The radius is now recomputed from the height whenever it changes - the height is set after creation, which is why a constant could never be right.

And the small row buttons take a moderate radius rather than the full pill: they are low and wide, and a stadium shape read as an oval. The fully rounded form stays where the reference puts it, on the main actions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:06:09 +01:00
r-zakarya 27a7a322d6 feat(android): the palette taken from the phone's global search
Not the desktop's slate any more: a blue gradient behind, blocks that are only translucent white laid over it, no borders at all, wide radii, white text. Depth comes from transparency, which is why the frames are gone - a hairline reads as a desktop application immediately.

There is no coloured accent in the reference either: primary and secondary differ by how much white they carry, 30 against 18 per cent. The 2FA code goes white, carried by its size. Nothing is uppercase - the second plane is marked by colour and size alone.

Three compiler traps on the way, all worth knowing: TBrush needs FMX.Graphics in the INTERFACE clause, a fresh TGradient already runs top to bottom, and TForm already owns a parameterless PaintBackground that shadowed the unit's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:47:44 +01:00
r-zakarya 1609b4d328 docs(android): Gitea as the transport, instead of hosting the endpoint
The Contents API maps term for term onto what is already written: it returns the file with its sha and demands that sha to write, answering 409 on a stale one - exactly the roles of the ETag and the 412. The desktop's bounded reread-remerge-repush loop would work unchanged.

It also settles the one remaining obstacle: nothing to host, no certificate, outside access for free. Records the calls, and why the answer is a private Gitea rather than GitHub - git history never forgets, so every past snapshot stays readable under whatever sync password was current then. Survivable on your own disk, not on a third party's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:36:43 +01:00
r-zakarya 40475b0204 docs(android): two open questions on the interface
The theme still is not the one from the global search - the blue and the pill were taken, not the spacing or the density. And a larger one: whether there should be Delphi controls at all, when the desktop shows no native controls either and serves the same HTML in a WebView.

Worth deciding before investing further in the current interface: every display fix made today would be thrown away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 20:00:04 +01:00
r-zakarya 8f9ebb8d47 feat(android): blue instead of cyan, everywhere at once
Cyan came from the desktop app. On the phone the reference is the system: its global search and its own apps are blue, and an app should look like the place it runs.

Accent #3B82F6 on the same slate, with white on the accent since blue is darker than cyan. The launcher icon and the launch screen follow - a cyan padlock in front of a blue interface would have read as a different app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:55:06 +01:00
r-zakarya 8dedcaecab fix(android): stop offering an open that cannot succeed
With the screen lock gone the cache stays sealed under a key the device can no longer use - correct, and final. But the app kept showing Open vault, which can only fail, and called the other button Sync when what is needed is a full replacement.

A session flag remembers that the cache refused to open. The file is still not touched; only the offer changes - Open vault disappears, the remaining button reads Download vault, and the status says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:45:25 +01:00
r-zakarya ceaba331d8 feat(android): the search field takes the shape Android uses for search
I had followed the desktop capture - a squared field with slightly rounded corners - without thinking of the search bar the phone itself uses. On a phone that pill is the reference, and the app should look like where it runs.

Edits become pills; the memo keeps its sober corners. A magnifier is drawn inside the field, as a circle and a handle rather than a glyph, since no font is guaranteed across devices. Drawing inside a TEdit works - it is only the clickable that does not, which is why the clear cross stays beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:38:37 +01:00
r-zakarya 5ef4987272 fix(android): a lock with no key locked the user out entirely
Removing the screen lock left no way forward: the cache is marked protected so it refuses to open, and the download that would have replaced it was blocked by the very gate I added to Sync yesterday. Neither door.

The gate now applies only when the device can actually authenticate. Demanding an authentication the phone can no longer produce protects nobody - the content comes from the server, and it is the sync password that guards it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:34:05 +01:00
r-zakarya 49377b0990 fix(android): the clear cross lives beside the field, not inside it
It was a TLabel, and in FMX a label does not take clicks - HitTest is False by default there. But fixing that alone would not have been enough: a child placed inside a TEdit does not reliably receive touches, the native control claims them across its whole surface.

It now sits in a wrapper next to the field, as a transparent rectangle with the cross drawn inside it - one Right and one Client, so there is no stacking order to guess either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:27:36 +01:00
r-zakarya aba873b9cc feat(android): follow the desktop's look, and a cross to clear the search
Section captions go uppercase, small and muted, the way the desktop app labels TWO-FACTOR (TOTP) or CUSTOM FIELDS - the eye jumps between captions without confusing them for values. Each field sits in its own bordered box: it is the border that separates values, not the spacing. Buttons take the same thin border instead of a solid grey block.

The 2FA code turns cyan and gains its countdown bar, so a glance says whether there is time to copy it. The seconds leave the text - the bar says it better.

And a cross clears the search, shown only when there is something to clear. A label rather than a button: inside a field, a cross is touched, not commanded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 19:19:22 +01:00
r-zakarya e128b85658 docs(android): the two safety rules, as they actually stand now 2026-08-15 18:53:47 +01:00
r-zakarya 301a999596 feat(android): a short grace period before backgrounding locks the vault
Locking the instant the app went behind made the commonest gesture painful:
copy a password, switch across to paste it, come back - and authenticate
again, with the 2FA code once more behind a prompt.

The screen is still hidden immediately and unconditionally; that is the part
that protects, and it still runs before the recents snapshot. Only the
plaintext now survives, for thirty seconds. Long enough for a round trip,
short enough that a phone left down does not stay open. Returning in time
restores the screen you left, detail or list.

Also drops the last ClearVault, in the manual open path. Nothing in the app
destroys the cache any more - only a successful write replaces it. An
unreadable file costs a few hundred kilobytes; three times today a wrong
verdict cost the cache instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:51:57 +01:00
r-zakarya 773e47330f fix(android): ask for the device credential alone when no biometric is enrolled
The re-seal never ran and left no trace, because a branch not taken logs nothing. It logs now, verdict and reason, whether or not anything happens - the absence of a line was itself the thing that cost time.

And the verdict may have been wrong: asking canAuthenticate for BIOMETRIC_STRONG together with DEVICE_CREDENTIAL can fail outright on a device with a pattern and no enrolled fingerprint, instead of keeping what exists. It now falls back to the credential alone, which is all the key needs, and AuthenticateThen presents whichever set answered - offering something else would draw the same refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:35:30 +01:00
r-zakarya d472dd4ef8 fix(android): encryption cannot see a dead key, so stop pretending it can
The retry I added yesterday never fired, and could not: the public key is
rebuilt through KeyFactory, so it is an ordinary RSA key with no link to the
Keystore. It encrypts perfectly well while its private half is permanently
invalidated. The re-seal therefore sealed the cache with a key nothing would
ever open again - the app asked for the sync password instead of the pattern.

Invalidation is observable at exactly one moment: reading with the private
key. LoadVault now drops the alias when the system names the cause, so the
next write mints a fresh pair instead of sealing another unopenable file.

And the caller that re-seals a plain cache asks for a fresh key outright:
protection reappearing on the device is precisely the event that invalidated
the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:21:50 +01:00
r-zakarya e4a50df64d fix(android): protection catches up with the cache, and a dead key gets replaced
After the screen lock was removed and put back, the app stopped asking for the
pattern. Two gaps, both mine.

The cache had been rewritten while the device had no lock, so under the plain
key - and nothing ever moves it back. Protection was decided at write time
only, and a write happens only when a new snapshot arrives; a 304 left the
file in the clear indefinitely. It is now re-sealed on open, where the
plaintext is already in hand.

And the invalidated RSA key was never replaced. Removing a lock, or enrolling
a new fingerprint, permanently invalidates a protected key while its alias
survives. EnsureWrapKey only created the pair when the alias was absent, so it
kept reusing a dead key: every encryption failed and every write fell back to
the plain key, forever. It now deletes and regenerates on a second attempt.

Measured on device: header 1 before, 3 after the first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:11:48 +01:00
r-zakarya fa424a3bc7 docs(android): section 1 is complete, six checks out of six
Removing the screen lock permanently invalidates every Keystore key that requires authentication, so the cache was unreadable for good and re-downloading was the only way out. The automatic open kept the cache and reported; only the explicit tap cleared it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:03:09 +01:00
r-zakarya bc89ec0010 i18n(android): the interface speaks English, like the desktop app
It was in French because the conversation is, and I never asked. The desktop
app the client mirrors says Add field, Attach file, Show previous versions -
someone moving between the two would notice the seam.

Every visible string moved: buttons, statuses, detail row captions, log lines
and the biometric prompt, plus the messages the modules surface through
LastStoreError and LastBiometricError.

Code comments stay in French. They address the author, not the user, and that
is already the language of CLAUDE.md and much of the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:59:31 +01:00
r-zakarya b900b5b609 fix(android): one frame around the search field, not two
The painted surface behind it and the style's own background were both drawing a rounded outline, nested. Now that make-style.ps1 recolours the style's, mine is redundant - and it was the outer of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:50:20 +01:00
r-zakarya e8e305b6fd feat(android): recolour the two native controls, and hide the log by default
Dark.style has no transparent template for an edit or a memo, so a StyleLookup at them fell back silently to the normal one and its grey background - the search field and the log were the last two things clashing with a painted interface. Since the style is vendored anyway, make-style.ps1 rebuilds it from Delphi's with those two backgrounds set to the palette. Reproducible, so a palette change does not quietly lose the patch.

The log is hidden now: it only serves diagnosis and it was taking a third of the screen at every launch. A Journal button beside the server settings brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:43:52 +01:00
r-zakarya 5a2774974a fix(android): the folder panel closes when you tap beside it
It stayed open until something was chosen, and there was no way to change your mind. A full-screen scrim now dims the background and takes the outside tap; the hardware back button closes the panel before anything else, which is what it is expected to do while something is open.

The list also scrolls: eight folders fit, twenty would have run off the screen. Rows are placed by coordinate inside the scroll box, with no alignment at all - the only arrangement FMX has not surprised me with today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:37:43 +01:00
r-zakarya 09f108aa8d feat(android): the last two styled controls give way
The search field and the folder combo were the only things still wearing the style, and they clashed with everything painted around them.

The edit stays native - a keyboard has to come from somewhere - but its background is ours now, with the styled one made transparent. The combo becomes a painted button opening a panel of choices, placed by explicit coordinates: FMX's stacking order proved unstable as soon as children are destroyed and rebuilt, so nothing here relies on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:26:02 +01:00
r-zakarya e5eb91cd3f feat(android): paint the interface instead of wearing a style
None of the styles Delphi ships is meant for a phone - grey gradients, sharp
corners, thick frames, all from the desktop era. And the day showed that
anything rendered through a style template may not exist in the style that is
actually loaded: the list row template, the search prompt and the detail row
each vanished in turn.

So the buttons are rectangles we draw: slate at rest, cyan for the one action
that matters, rounded, no gradient, with a press feedback a TRectangle does
not have on its own - without it you cannot tell whether the tap landed. The
form's own fill carries the background, the list drops its frame, labels take
the palette.

A style stays loaded underneath, as a base only: edits, list and memo are
native controls we are not going to rewrite, and with no style at all they
would render light.

Palette lives in PMA.Theme, the same slate and cyan as the desktop app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:55:54 +01:00
r-zakarya 25dc524585 feat(android): the launch screen shows the padlock, not Delphi's flame
The launcher icon and the launch screen are different resources. On Android 12+ the theme points windowSplashScreenAnimatedIcon at splash_vector, which was Delphi's logo, over a #FFF background - a white flash before a dark app, every single launch.

Same padlock, drawn at 288 units with its content inside the central 192 the system guarantees. splash_background goes slate, in the light variant too since the app has only one theme. For Android 11 and earlier, which have no SplashScreen API and merely paint windowBackground, splash_image_def composes the background and the logo itself.

One source for the vector, copied onto the density and night variants by repack: four identical files in the repo would eventually diverge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:21:26 +01:00
r-zakarya fdeb870541 chore: track the launcher icons, the blanket *.png rule was swallowing them
The rule exists for screenshots dropped in the repo root during dev. Launcher icons are content: they regenerate from make-icons.ps1, but tracking them means not having to know that - nor depending on System.Drawing to build an APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:12:08 +01:00
r-zakarya 202b93c246 feat(android): a padlock icon instead of Delphi's default
Cyan on slate, the accent the desktop app already uses. Adaptive vectors for
Android 8+ - background covering the full 108 units since the launcher applies
its own mask, foreground kept inside the central 66 that no mask can crop -
plus a monochrome layer for Android 13 themed icons, where the keyhole has to
be a hole in the path rather than a shape in another colour.

PNGs for older launchers come from make-icons.ps1, same drawing, and carry
their own rounded square since nothing masks them.

They are copied over Delphi's in repack-apk.ps1, for the same reason as
libsk4d.so: the Android_LauncherIcon and Android_AdaptiveIcon properties ought
to be enough, and nothing deploys them here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:11:31 +01:00
r-zakarya 65e6ba6a54 fix(android): draw the search hint ourselves, TextPrompt needs a style object
Dark.style has no object for the prompt text, so the search field sat there
empty and mute. A label inside the field, hit-test off so the tap goes
through, shown only while the field is empty.

Fourth time today the same conclusion: whatever FMX renders through a style
template may or may not exist in the style actually loaded. Built explicitly,
it is there.

Also noted: the row height was never wrong. 56 logical units on a 3x display
are the ~166 device pixels the screenshot showed - only the second line was
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:04:01 +01:00
r-zakarya 31ee1b45c6 fix(android): build the list rows by hand, styles cannot be trusted with them
The dark style took the row template with it: no username under the name, no
icon, and rows three times too tall. listboxitembottomdetail simply does not
exist in Dark.style, and Text/Detail/Bitmap render whatever the current style
decides.

Rows are now built explicitly - an image and a body layout, Left and Client,
where there is no order to guess - the same conclusion as the detail screen.
Same amount of code, no dependency on a template that may or may not be there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:55:00 +01:00
r-zakarya b5ccf63a85 feat(android): Skia renders the text, so Arabic reads properly
An Arabic attachment name displayed scrambled. Not a missed setting: FMX's original text engine does not shape complex scripts - no bidirectional reordering, no letter joining. Separating the size from the name limited the damage without removing it. Skia carries HarfBuzz and does the work.

GlobalUseSkia goes before Application.Initialize; the canvas is chosen at init and switching later does not reclaim what is already built. The price is libsk4d.so, 25 MB raw, and an APK from 13 to 22 MB.

Deploy rebuilds library\\lib just before calling aapt, so a hand-placed library disappears. A proper DeployFile entry was tried first - MSBuild ignores it, as it ignores every other one in this project. Hence repack-apk.ps1, which redoes aapt, zipalign and signing on the folder already prepared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:44:43 +01:00
r-zakarya 49efccdd59 feat(android): settings live on the device, dark theme, and the app is called Coffre
URL and token are no longer constants. They sit in settings.bin, sealed with the no-auth Keystore AES key - deliberately weaker than the vault: they must be readable to rebuild a vault that no longer exists, and a bearer token only opens ENCRYPTED data, worthless without the sync password that stays in the guarded vault. Retyping 64 hex characters after every cache purge was the real risk: the risk of writing them down elsewhere. They are saved only after a fetch that worked - proven settings, not typed ones.

The token and sync password moved to the gitignored local include, as first-run defaults only. Tracked source now carries no secret, and leaving them empty is a viable configuration: the app asks.

Theme: Delphi's Dark.style, copied into the repo and linked as a resource rather than deployed as a file - this project's deployment puts nothing beside the app, which is the same hole that forces the manifest and dex staging. A failure to load is never fatal; the default look is ugly but works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:33:56 +01:00
r-zakarya 861d721513 docs(android): plan caught up with what the device confirmed 2026-08-15 15:23:04 +01:00
r-zakarya 3a9248c6c7 fix(android): a fresh container per entry, and the filename left to read in its own direction
Row order alternated: correct on first open, upside down on the next. Neither the stacking convention nor an explicit Index stabilised it. Since the first population is reliable, there is never a second one - the scroll box is destroyed and rebuilt per entry. Costs one control; removes a whole class of oddities.

The attachment size moved from the value into the caption. An Arabic filename followed by a latin size mixes two reading directions in one line, and FMX's bidirectional rendering makes a mess of it. Apart, each reads its own way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:21:19 +01:00
r-zakarya bbb44a3787 fix(android): sync went through the door it should have knocked on, and rows kept flipping
Synchroniser rebuilt the vault from the server and displayed it without ever asking for the pattern - the same result as Ouvrir le coffre, one button away, with the protection bypassed. It now passes the same gate, conditioned on a protected cache existing on this device, since that is what expresses the user's choice.

Row order was not merely inverted, it alternated: right on first open, upside down after a lock. FMX's stacking conventions already differ between TLayout and TVertScrollBox; after rows are destroyed and rebuilt they are not even constant. Each row now states its Index explicitly rather than trusting insertion order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:15:56 +01:00
r-zakarya 4c6376edbf fix(android): the detail rows were upside down
Inside a TVertScrollBox, Align=Top children stack in creation order - the opposite of a TLayout, where the last created ends up on top. The convention that is right for the unlock screen produced an inverted form here, with the 2FA code relegated to the bottom when it is the reason you pull the phone out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:09:08 +01:00
r-zakarya 7229028060 feat(android): show attachments, without carrying their bytes
One attachment took the cache from 13 to 174 KB: the content travels base64 in the snapshot and is decrypted in memory with the rest. TVault reads name, type and size and DROPS content_b64 - holding it for the whole vault while nothing displays it would be paying for nothing.

The detail now says a document exists. Saving it is what remains, and what blocks that is not code but where a file lands on Android 10+. When that is settled, the content re-reads from the raw JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 15:05:08 +01:00
r-zakarya 7939d268f3 feat(android): mark the clipboard sensitive, and lock after 3 min idle
Since Android 13 the system shows a preview of whatever was just copied, at the bottom of the screen. For a password that is precisely what we are trying to avoid: it appears in clear, outside the app, for several seconds. The flag that suppresses it is a ClipDescription extra, which IFMXClipboardService does not expose - hence going straight to ClipboardManager. Note Jcontent_ClipboardManager, not JClipboardManager: the latter is the deprecated android.text one, which knows only bare text and has no ClipData to carry an extra.

Backgrounding already locks; the idle timer covers the phone left face-up on a table with the app open. It samples timestamps every 15 s rather than counting seconds - a wake-up per second would cost battery for no precision that matters.

Attachments dropped from the plan: this vault has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:58:27 +01:00
r-zakarya 0e39cf7e56 docs(android): the expired auth window no longer costs the cache 2026-08-15 14:37:56 +01:00
r-zakarya f3a8631440 docs(android): the cache chain is verified on device
Write with no prompt, read under the device credential, cache surviving a cold start with no derivation, and a silent 304. Records the three traps that hid behind it, none of which the compiler could see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:28:58 +01:00
r-zakarya c4d2bdde73 fix(android): OAEP masks with SHA-1, which is the only MGF1 the key allows
The read failed with IllegalBlockSizeException and, in logcat, KeymasterUtils status -28. setDigests() authorises the MESSAGE digest only; the MGF1 digest is a separate authorisation whose default is SHA-1 and stays SHA-1 unless setMgf1Digests is called (API 31+). Asking for MGF1-SHA-256 at decrypt time therefore hands the hardware a key that refuses.

Encryption did not complain, because the rebuilt public key is served by the software provider, which accepts any MGF1. So we produced perfectly valid ciphertext that the hardware would not read - the worst shape of bug, since it only surfaces on the next launch. Both sides now pass SHA-256 for the message and SHA-1 for the mask; SHA-1 there weakens nothing, a mask generation function needs no collision resistance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:17:26 +01:00
r-zakarya 8dc0840e03 fix(android): declare FMX's biometric activity, and stop LoadVault from deleting the cache
The prompt raised ActivityNotFoundException: FMX does not show BiometricPrompt from our NativeActivity, it launches its own BiometricFragmentActivity, which lives in fmx.dex.jar but has to be declared in the manifest. Given a translucent framework theme - it hosts the system dialog and draws nothing itself, and the generated resources contain no AppCompat.

Then the read failed and the cache vanished with no trace. LoadVault sorted 'key lost' from 'not authenticated' on the exception message and deleted the file when it guessed the former - so a wrong guess destroyed the cache and the only record of why. It now reports through LastStoreError and deletes nothing; the caller has the log and the context. OpenLocalNow shows the reason, after ShowUnlock rather than before, which was overwriting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:11:20 +01:00
r-zakarya 1dc4f3bdb6 feat(android): wrap the cache key with an asymmetric Keystore pair
Measured on device: android.security.keystore.UserNotAuthenticatedException on every write. An AES key created with setUserAuthenticationRequired demands a recent authentication to ENCRYPT as well as to decrypt, and the write follows a silent sync with no prompt. The very first write only succeeded because the phone had just been unlocked - a test that passes, then stops passing.

Now an RSA pair: the public half wraps a throwaway AES key with no prompt, the private half - the guarded one - unwraps it. Authentication is required at read time and nowhere else. Two documented traps handled: the public key returned by the Keystore carries the private key's constraints, so it is rebuilt from its encoded form; and that rebuild changes provider, so the OAEP parameters are passed explicitly on both sides - the Keystore defaults MGF1 to SHA-1 where the transformation name suggests SHA-256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:00:35 +01:00
r-zakarya 09048410e1 fix(android): report the biometric failure instead of destroying the cache
The launch-time auto-open received boUnavailable and wiped the cache, seconds after the write path had proved the device could authenticate. The diagnosis was wrong and the reaction irreversible. An automatic action now never clears the vault: it logs the cause and leaves the user on the unlock screen. Only an explicit tap on Open, followed by a clean refusal from the system, may clear it. PMA.Biometric keeps the reason in LastBiometricError, and a Java-layer exception now maps to boFailed rather than boUnavailable - an exception says nothing about the device. The log memo also wraps: it was cutting off exactly the end of the message, which is where the cause is named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:42:07 +01:00
r-zakarya 5187da3070 docs(android): the adb reverse tunnel dies with the USB link 2026-08-15 13:00:28 +01:00
r-zakarya 57a295ad36 fix(android): set the biometric strengths before asking what the device can do
A pattern lock reported "no authentication here", so the cache was written
under the plain key.

FMX builds its authenticator mask from FBiometricStrengths, and that field
starts as the EMPTY SET - the constructor never initialises it. On API 30+
CanAuthenticate therefore calls canAuthenticate(0), which never answers
"available", whatever the device can actually do. Asking before setting the
strengths cannot succeed.

Both entry points now go through AcquireService, which sets Strong +
DeviceCredential and only then hands back the service. DeviceCredential is
the point: a phone with a pattern or a PIN and no enrolled fingerprint is
the common case, not an edge one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:48:43 +01:00
r-zakarya 4258e9b559 fix(android): USE_BIOMETRIC, and never lock the cache behind a door we cannot open
Opening the vault threw java.lang.SecurityException: USE_BIOMETRIC is not in
Delphi default permission list, and CanAuthenticate raises instead of
answering false. So the fault showed up neither at compile time nor at
startup, but on the first unlock - after the cache had already been written
under an auth-protected key.

The permission is the shallow half. The deep half is that SaveVault was
called with a hard-coded True: we asked for a key guarded by a prompt that
could not be shown, producing a file that was unreadable forever. It now
receives BiometricAvailable, evaluated on the main thread before the worker
starts.

BiometricAvailable and AuthenticateThen also swallow what comes up from the
Java layer and map it to boUnavailable. The caller has a path for every
TBioOutcome and none for an exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:09:29 +01:00
r-zakarya 6d31bf83d2 docs(android): the res tree comes from any IDE build, not the 32-bit one 2026-08-15 11:54:29 +01:00
r-zakarya 4805c9ac31 docs(android): building and installing the APK without the IDE 2026-08-15 11:52:44 +01:00
r-zakarya 9a4170c44f feat(android): folder filter, icons, sort, template, updated_at, FLAG_SECURE
Works through the plan's §2 and §3 items that need no hardware and no
platform decision.

- allowBackup="false". The private directory holds vault.bin; a cloud
  backup has no business in a password manager's path, even though what
  would leave is encrypted by a key that stays in the chip.
- FLAG_SECURE, Release builds only - in debug it also blocks development
  screenshots. Complementary to hiding on WillBecomeInactive rather than
  redundant: that one covers the recents preview, the flag also covers
  manual captures, screen recording and casting to an external display.
- List sorted by name (snapshot order is DB order, arbitrary to the eye),
  entry icons decoded once per vault rather than on every keystroke, and a
  folder dropdown whose selection survives a background refresh. No tag
  filter: text search already matches tags.
- template and updated_at read and shown in the detail. updated_at is also
  step 1 of the write path - nothing is arbitrable without it.

Android code is now type-checked without deploying: dccaarm64 reaches the
link step and fails only on the NDK's ldandroid.exe. A JNI declaration that
does not compile no longer waits for an APK to say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:23:29 +01:00
r-zakarya 3bb6f6f903 feat(android): show first, sync after
The local vault now opens by itself at launch and the GET runs behind it,
silently. A 304 shows nothing; a network failure shows nothing either -
offline is a normal state, and an alert on every launch only teaches people
to dismiss alerts. Only a new snapshot manifests, by refreshing the list.

That silence is what puts the sync password inside the Keystore-encrypted
file next to the vault: without it a new snapshot cannot be opened, so every
edit made on the desktop would raise a password prompt at launch. The file
already holds every password in plaintext; this one adds almost nothing to
its exposure. A typed password still wins over the stored one, which is how
you fix one that no longer works.

Adopting a snapshot while the app is already open rebuilds the list in place
instead of navigating - mandatory even from the detail screen, since list
rows carry indices into the old vault and a tap would open the wrong entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:14:44 +01:00
r-zakarya ba0d3c1d3c docs(android): plan of the remaining work 2026-08-15 10:56:49 +01:00
r-zakarya acf4b466e0 fix(android): breathing room under the last detail row 2026-08-15 10:50:07 +01:00
r-zakarya 20ba2438df feat(android): TOTP codes and custom fields in the detail screen
The detail screen showed site, username and password and silently dropped
everything else - custom fields, notes, 2FA. It is now built per entry
rather than from a fixed template: every row is a caption, a value, and
whether it is secret, so reveal and copy exist once instead of once per
kind of field.

Two rows stay special on purpose. A note's body is stored in the password
column, so it gets its own caption and no mask. The 2FA code is the only
live row - refreshed every second, placed first, since it is what you pull
a phone out for.

PMA.Totp takes bare base32 or an otpauth:// URI; both travel in snapshots
depending on how the entry was created. Checked against all six RFC 6238
vectors, including T=20000000000, which overflows 32 bits.

The rows hold plaintext, so clearing them is part of locking, not of
navigating away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 10:35:09 +01:00
r-zakarya 3232f6f163 fix(android): the ETag dies with the plaintext it describes
Unlock, lock, download again: the app sent If-None-Match, got 304, and had nothing to show - on Windows there is no local cache to fall back on, so the only way back in was restarting. FETag outlived the vault it belonged to; LockVault now clears it. The 304 branch also stopped re-reading the cache file (which can fail on an expired authentication) and serves the plaintext it already holds - in memory, or the copy read at the start of the call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 06:04:43 +01:00
r-zakarya 0ddfe5c5fd feat(crypto): wire AES-GCM and PBKDF2 to Windows CNG
The viewer compiled for Win64 but died on first use: AesGcmDecrypt existed
only as JNI, so the desktop build downloaded 27 KB and then refused it.
Windows has the same primitive in bcrypt.dll - no DLL to ship, no OpenSSL,
and BCryptDeriveKeyPBKDF2 takes bytes, so none of the char[] encoding doubt
that keeps the Pascal loop around on Android.

This exists so the client can be exercised on the desktop; deploying an APK
to watch a list redraw is a slow way to build a UI.

selftest.dpr replaces the crypto-vector button the viewer dropped: same
known-answer vector, console, exit code = failures. Measured here - Pascal
1670 ms, CNG 528 ms, identical keys on the non-ASCII password.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 05:59:36 +01:00
r-zakarya bf7aac478b feat(android): read-only viewer - unlock, search, detail
Replaces the crypto test bench with the three screens the client actually
needs. PMA.Vault holds the decrypted snapshot and answers the search;
mainApk builds unlock/list/detail in code rather than three .fmx files.

Two safety rules live in the UI because nowhere else can hold them: the
plaintext is dropped when the app backgrounds (screen hidden on
WillBecomeInactive, which precedes the recents-preview capture), and the
clipboard is cleared after 45 s only if it still holds what we put there.

The search deliberately ignores the password field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:47 +01:00
r-zakarya 4effc3611d feat(android): prefill the sync password from a local, untracked include
Asked for, but it opens the whole vault once the container is downloaded, so it does not belong in a versioned file - even in a repo with no remote. It lives in PMA.LocalConfig.inc, gitignored, with a template checked in so a fresh clone knows what to create.
2026-08-15 01:04:08 +01:00
r-zakarya 3cbfd54c26 fix(android): KeyStore.load takes a LoadStoreParameter, not an Object
The one-argument overload is load(LoadStoreParameter). Declaring JObject made the bridge look for a method that does not exist - reported at runtime as 'method not found', with nothing at compile time. Same class of mistake as generateSecret earlier: the JNI signature is built from the declared types, so an approximate type is a silent break.
2026-08-15 00:56:22 +01:00
r-zakarya 60b806536a fix(android): the prompt field is CancelButtonText, not NegativeButtonText
NegativeButtonText is the Android API name; FMX's record exposes CancelButtonText.
2026-08-15 00:51:24 +01:00
r-zakarya 1e7477cdd6 fix(android): overload both Cipher.init declarations, restore CRLF line endings
Delphi refuses a second declaration when the first is not marked overload, so the three-argument init was reported as too many parameters. Also adds Androidapi.Jni to silence the non-inlined TJavaArray.Length hints.

My ASCII normalisation had rewritten the sources in LF, which RAD Studio flags on every unit; a .gitattributes rule now pins CRLF for Pascal sources so git does not undo it.
2026-08-15 00:49:37 +01:00
r-zakarya 9649dac7b2 feat(android): protect the local vault with biometric or device credential
The Keystore key now requires authentication, valid for 30 s afterwards - short enough that an unattended phone is not exploitable, long enough to decrypt and display. Time-bound rather than a per-use CryptoObject: BiometricPrompt's callback would need a Java class compiled into a jar, while FMX already bridges it.

Two aliases, because the auth constraint is fixed when the key is created and cannot be added later. The file header records which key wrote it, so a read knows whether to authenticate first.

A device with neither fingerprint nor lock screen cannot hold such a key, so SaveVault falls back to the plain key rather than giving up the cache. And a failed authentication no longer wipes the cache: the vault is intact, it is the user who cancelled - erasing it would cost a download and 4 s of derivation for a dismissed prompt.
2026-08-15 00:42:50 +01:00
r-zakarya 5fdb7f914e feat(android): keep the decrypted vault locally under an Android Keystore key
Opening a container costs ~4 s and the iteration count belongs to the desktop format, so the fix is to stop repeating it: derive once per snapshot, then read a local AES file which the hardware accelerates. The key is generated inside the Keystore and never leaves it - the file alone is useless, even rooted.

ETag and vault are encrypted together on purpose: an ETag outliving an unreadable vault would make the server answer 304 with nothing to show. A failed read wipes the cache rather than guessing.

Also normalises every Delphi source to pure ASCII: Delphi reads a BOM-less file as ANSI, so accented comments came back as mojibake, and each PowerShell rewrite added another layer.
2026-08-15 00:37:59 +01:00
r-zakarya 3c1525e46f feat(android): end-to-end read path validated on device, add the container diagnostic
GET in 80 ms, decryption in 3893 ms, entries and trash counted, second call answers 304. try-open.js opens a container with Node so a failure can be attributed: a GCM tag that does not verify means wrong password OR altered file, and the app cannot tell which.
2026-08-15 00:31:01 +01:00
r-zakarya a641669150 fix(android): keep the ETag only when the decryption that followed succeeded
The harness stored the ETag on download, so a first attempt with no password left a valid tag behind: the next call answered 304 and there was nothing to decrypt, precisely when the right password had just been typed. Sending If-None-Match without holding the matching plaintext trades a download for having no data - the real client needs the same rule, the tag belonging to the cached vault.

Also drops an em dash from an exception message: Delphi reads a BOM-less source as ANSI and it reached the device as mojibake.
2026-08-15 00:06:30 +01:00
r-zakarya ccab734d61 fix(android): re-enable the fetch button on every exit path, prefill the test parameters
The Exit in each error branch jumped past the re-enable at the end of the anonymous method, so one failure disabled the button for good. Wrapped in try/finally.

URL and token are now constants, with the port chosen per platform: 80 on Windows against Apache, 8080 on Android through adb reverse. The sync password is never prefilled - it opens the whole vault and this file is versioned.
2026-08-14 23:47:54 +01:00
r-zakarya d9cadf2cac fix(android): create the top-aligned controls in reverse so they appear in order
FMX stacks Align=Top controls in reverse creation order, so the URL field ended up under the buttons.
2026-08-14 23:41:41 +01:00
r-zakarya 8796bb341d feat(android): HTTP client for the sync endpoint, with ETag reuse
Fetches the opaque container and the ETag; a 304 skips both the download and the 4-second derivation, which is the whole point of keeping the tag between calls. Redirects are not followed - one to another host would send the bearer token there.

Local testing goes through adb reverse tcp:8080 tcp:80, which makes the phone reach the PC as 127.0.0.1 and satisfies the endpoint's loopback exemption. That needs usesCleartextTraffic in the manifest, marked for removal before shipping.
2026-08-14 23:37:06 +01:00
r-zakarya c684d9073f feat(android): adopt the platform PBKDF2, record the timing and what follows from it
Measured on device: Android derives the same key as WebCrypto on a non-ASCII password, so PBEKeySpec does encode as UTF-8 - the doubt that justified the Pascal implementation was unfounded. It is barely faster though, 3869 ms against 4139 ms: PBKDF2 gets no hardware acceleration.

So 4 seconds per container is incompressible, and the iteration count belongs to the desktop format. The answer is not to speed up the derivation but to stop repeating it: derive once per snapshot, then keep the vault locally under an Android Keystore key. Written up in android-client/README.md.
2026-08-14 23:30:15 +01:00
r-zakarya 307bbeae83 chore: ignore the generated project resource too 2026-08-14 23:23:28 +01:00
r-zakarya ffe203a1ef chore: untrack the Android build output
The APK, the .o files and the dex are rebuilt on every compile; versioning them adds 12 MB per build and nothing else.
2026-08-14 23:23:16 +01:00
r-zakarya 4cc79a5216 fix(android): declare KeySpec so the native PBKDF2 call resolves, and report why it fails
generateSecret takes a KeySpec; passing a JObject made the bridge emit a signature no method matches, and the call failed at runtime. Swallowed as 'unavailable', which hid my own mistake for a full test round - the failure now carries its exception message.

Measured on device: the Pascal PBKDF2 takes 6651 ms, unusable per unlock. AES-GCM through JNI works, plaintext and hash match the vector, so the container format is validated end to end.
2026-08-14 23:22:56 +01:00
r-zakarya c607e26c4a feat(android): measure the native PBKDF2 against the vector instead of guessing
The Pascal loop takes 1736 ms on a desktop CPU, so several seconds on a phone - too slow for every unlock. SecretKeyFactory is native and roughly an order of magnitude faster, but PBEKeySpec takes a char[] and the provider picks the encoding, which was the reason for avoiding it.

The non-ASCII vector settles it on a real device: same key means the provider uses UTF-8 and we adopt it; a different key means the Pascal implementation stays. DecryptContainer keeps using the Pascal one until that measurement says otherwise.
2026-08-14 18:33:56 +01:00
r-zakarya 386bf74a2a feat(android): crypto self-test harness on the new FMX project
Two stages tested separately, because a global failure says nothing about where: PBKDF2 against the known derived key - which also runs on Win64, so that stage validates in seconds without deploying to a phone - then AES-GCM against the plaintext hash, Android only.

The vector is embedded as a unit rather than read from a file: no network, no deployment, so a first-run failure can only come from the decryption itself. Work runs on a thread; 600k HMAC rounds on the UI thread would have Android kill the app for not responding.
2026-08-14 18:29:12 +01:00
r-zakarya 02d89c58d2 feat(android): container crypto unit plus a known-answer test vector
PBKDF2 is implemented in Pascal rather than through javax.crypto SecretKeyFactory: PBEKeySpec takes a char[] and the provider decides how those chars become bytes, so a non-ASCII sync password would derive a different key than the browser and fail as 'wrong password'. AES-GCM does go through JNI - one call, hardware-backed, and WebCrypto's tag-appended layout is exactly what Cipher expects.

The vector is generated with WebCrypto itself and uses a non-ASCII password on purpose; an ASCII-only vector would have hidden that bug. It records the derived key separately from the plaintext so a failure points at one stage instead of the whole chain.
2026-08-14 18:04:39 +01:00
r-zakarya 90ac75525a fix(sync-php): read Authorization via getallheaders and tolerate ETag forms
Apache commonly strips Authorization before PHP sees it, so every request looked unauthenticated on a stock XAMPP and answered 404.

If-None-Match is now compared on the bare hash: a validator that lost its quotes, or a weak W/ tag, would never match and each request would silently re-download the whole snapshot - expensive on mobile data and invisible in the logs, a 200 looking perfectly normal.
2026-08-14 17:58:48 +01:00
r-zakarya ec4fa433a9 feat(sync): read-only PHP endpoint serving the encrypted snapshot
Alternative to WebDAV, and the base for an Android client. The server never decrypts: it stores and serves opaque bytes, so a compromise yields ciphertext plus size and mtime.

GET only for now, with a strong ETag and If-None-Match; PUT answers 405 explicitly so a client fails loudly rather than believing it wrote. A bad token answers 404 like a missing snapshot, so an unauthenticated caller learns nothing. HTTPS is required - the bearer token rides a header on every call.
2026-08-14 17:41:34 +01:00
r-zakarya c74b708b39 fix(import): count the trash when deduplicating by uuid
The map was built from state.entries only, so a container entry whose uuid existed just in the trash looked fresh and inserted a second row sharing that uuid - the identity the sync engine relies on. Same blind spot as the resurrection bug in applyRemoteSnapshot.

Also documents F9 and warns that emptying the trash is the wrong way to clean duplicate uuids: the tombstone would kill the live twin on the other device.
2026-08-07 17:35:40 +01:00
r-zakarya ebcd75fd2e feat(backup): include the trash in exports and auto-backups
A backup that silently drops the trash is not a backup: export, empty the vault, import, and the trashed entries were gone for good with nothing said. Both containers now carry state.trashed with deleted/deleted_at, and deleted_at is preserved so a restore resumes the auto-purge clock instead of restarting it - hence its addition to the GET echo.

The sync snapshot deliberately does NOT: the trash is device-local, and re-publishing deleted rows is what produced the duplicate-uuid bug. The two identical export loops were folded into buildBackupEntry first, so the trash handling exists once.
2026-08-07 15:04:41 +01:00
r-zakarya 125cb11756 docs(tests): mark I6 validated — every scenario now passes 2026-08-07 14:55:47 +01:00
r-zakarya afe47259d7 fix(unlock): focus the username first when it is empty, on the quick-search path too
The autofill hotkey already picked whichever field still needed filling; quick search forced the password, so a first unlock or one without a remembered username landed the cursor in the second field.
2026-08-07 14:53:36 +01:00
r-zakarya 476d6660ad fix(hotkeys): a locked vault must not hand the foreground back
Both locked-vault paths cancelled the autofill with the default refocus, which returns the foreground to the captured target - right after Delphi had raised our window. Ctrl+Shift+Q flashed for a frame then Notepad took over; Ctrl+Shift+L moved the window twice because focusApp had to take it back.
2026-08-07 14:48:12 +01:00
r-zakarya d242f09c53 docs(tests): mark F1/F3/F4 validated, section F complete 2026-08-07 12:50:14 +01:00
r-zakarya c3c6bce406 fix(sync): a trashed entry is no longer resurrected as a duplicate
The trash is a soft delete: no tombstone is written and the rows are absent from state.entries, so the merge read their uuids as missing locally and POSTed them back as fresh live entries. Trashing 30 entries then syncing produced 30 live duplicates alongside the trashed originals - two rows sharing one uuid.

applyRemoteSnapshot now loads the trash and skips any uuid found there. Emptying the trash is what writes the tombstone that propagates the delete.
2026-08-07 12:38:07 +01:00
r-zakarya b9b098fe12 fix(editor): fire an input event when the generator fills the password
Assigning .value fires nothing, and the code called soDirtyCheck by hand - so the strength bar, which listens to input, stayed frozen on the previous password. Dispatching the event covers every listener on the field instead of one named handler.
2026-08-07 12:03:42 +01:00
r-zakarya 485dd3631e docs(tests): E5 validated by code inspection, distinguish it from E9
Web storage and process memory are different artefacts; the dump scan says nothing about the former. Also flags the removeItem('cryptoKey') cleanup as deliberate, not dead code.
2026-08-07 11:46:18 +01:00
r-zakarya c1e5d14eac docs(tests): mark H2-H6 validated 2026-08-07 11:02:05 +01:00
r-zakarya af1157f3b0 docs(tests): note that the trash is silently excluded from every backup path 2026-08-03 00:41:15 +01:00
r-zakarya 5236aa8b98 fix(busy): gate the document-level shortcuts too
inert removes elements from focus and hit testing, but shortcuts are listened for on the document and never traverse an element. Ctrl+A still selected every entry under the overlay, Ctrl+K would open the palette behind it, j/k moved the cursor - and Delete ran a batch delete while a sync was writing to the same rows.
2026-08-03 00:04:56 +01:00
r-zakarya a36164174e docs(tests): record the four secret-consuming paths and how to test them
Includes the authenticated WsgiDAV recipe (Basic only, digest off) and the change-event race that can produce a misleading 401.
2026-08-03 00:02:27 +01:00
r-zakarya c8518c806d docs(tests): mark F2 validated, note that the file existing proves nothing
A backup encrypted with an empty password looks perfectly valid on disk; only a successful import proves the stored password reached the encryption.
2026-08-02 23:55:04 +01:00
r-zakarya f8e78f6940 docs(tests): add E9, the renderer memory check that caught the backup password
Records the staging, how to pick the renderer (the GPU child is bigger and holds no JavaScript, so scanning it proves nothing), the soState validity marker, and the measured before/after.
2026-08-02 23:37:07 +01:00
r-zakarya 5ce18e7ab5 fix(prefs): stop pulling secret prefs into the renderer just to render the UI
A renderer dump taken after a quick-unlock, with nothing opened, contained the auto-backup password in cleartext - inside an ExecuteJavaScript string and in the DevTools protocol buffers - and it survived the auto-lock that wipes the vault key.

New prefs/has answers set-or-not without moving the value. The settings panel uses that; only the paths that encrypt a backup, test the WebDAV connection or run a sync fetch the secrets themselves.
2026-08-02 23:21:03 +01:00
r-zakarya 3c14836b15 docs(tests): add D6 for the sticky header spacing 2026-08-02 00:11:06 +01:00
r-zakarya 7074e7ff93 fix(list): keep the header gap constant when it sticks
offsetHeight feeds --hdr-h, where the sticky pagination parks, and it ignores margins - so the 20px gap under the title vanished as soon as the header stuck. Turned into padding: measured and painted, so the spacing stays constant and opaque.
2026-08-02 00:09:33 +01:00
r-zakarya ecaa226354 docs(tests): record that D5 needs checking in all three view modes 2026-08-02 00:06:09 +01:00
r-zakarya 4c734de266 fix(list): give the content header a fixed height
min-height still let a child grow the row, and a couple of stray pixels from the selection bar pushed the grid down in table and list view. A fixed height makes overflow purely visual - nothing inside can move what follows.
2026-08-02 00:04:52 +01:00
r-zakarya 8c633b69de docs(tests): mark D5 validated 2026-08-02 00:03:02 +01:00
r-zakarya a12f3a2c91 fix(list): toggle a checkbox in place instead of rebuilding the grid
renderGrid replaces every card, so the scroll container jumped back to the top: ticking a card near the bottom of a long list threw the user back to the first row. Only the affected card and the selection bar are updated now.
2026-08-01 23:59:53 +01:00
r-zakarya 29ba069659 fix(list): pin the selection bar to the height the header reserves
It was a few pixels taller than the reservation, so selecting anything pushed the whole grid down - the shift moving the bar into the header was supposed to remove.
2026-08-01 23:56:15 +01:00
r-zakarya cb0a366e52 docs(tests): mark B19 validated 2026-08-01 23:53:08 +01:00
r-zakarya 69b0e84f63 fix(editor): whitelist the bar controls, not the whole bars
content-header and pagination were exempt from click-away in full, so their large empty areas were dead: clicking beside the title or next to the page buttons left the editor open. Only the actual controls are exempt now.
2026-08-01 23:48:54 +01:00
r-zakarya dc7b18736e fix(dnd): drop targets only light up for our own entry drags
They accepted any drag then looked for ids in text/plain: dragging text selected in a cell produced NaN, filtered out, so the target invited a drop that could do nothing. Entry drags now carry application/x-pm-entries, mirroring the folder MIME, and all four targets check it before accepting.
2026-08-01 23:32:47 +01:00
r-zakarya 2027d9b820 docs(tests): mark G6 validated 2026-08-01 23:25:00 +01:00
r-zakarya 69a62994b4 fix(views): drop the selection whenever the view changes
Checked ids belong to the view they were made in. Carried into the trash they were LIVE entries while the trash bar offers Delete forever, so a purge could destroy rows that had never been trashed. The checkbox-less views were the visible symptom of the same problem; one rule replaces the special case.
2026-08-01 23:14:31 +01:00
r-zakarya 9e5c5945f9 fix(views): clear the selection when entering a view without checkboxes
The batch bar survived into vault health, the authenticator grid and the audit log, none of which render checkboxes - it kept offering Delete, Move to folder and Add tag on entries the user could no longer see selected.
2026-08-01 22:49:42 +01:00
r-zakarya fb65f4b7d6 fix(hibp): put the progress pill in the toast stack, bottom right
Free-floating it either covered the cards or the sidebar navigation. Inside .toast-container it shares the corner with the toasts, and column-reverse keeps it above them.
2026-08-01 22:41:34 +01:00
r-zakarya 961d77534f fix(hibp): move the progress pill off the sidebar and abort the scan on toggle-off
The pill was pinned to the left edge and covered the navigation, hiding Trash. It also kept counting after Breach check disabled, the workers carrying on with one network round-trip per entry.
2026-08-01 20:12:09 +01:00
r-zakarya fd03c1da25 fix(list): keep the sticky bars above the row checkboxes
The pagination bar and the table header sat at or below the checkbox z-index, and at equal values the later DOM element wins - the cards come after both, so checkboxes of rows scrolling underneath were painted over them. Layers are now explicit: check 2, table header 3, pagination 4, content header 12.
2026-08-01 20:05:51 +01:00
r-zakarya c9068a6732 feat(hibp): show a progress pill while the breach scan runs
Bottom-left, out of the way of the toasts, with a live count. Deliberately not the busy overlay: the scan is network-bound and can take a while, and there is no reason to lock the vault while it runs.
2026-08-01 19:58:16 +01:00
r-zakarya e80703d2c4 fix(health): invalidate the cache when HIBP results change
The dashboard reads its pwned column from state.hibpResults but its cache was computed before the scan finished, so enabling the breach check from the health view showed 0 pwned while the entry cards already carried the flag. Disabling had the mirror problem: results cleared, dashboard still counting them.
2026-08-01 19:49:07 +01:00
r-zakarya 6bee69eb1a feat(health): show the per-category breakdown under the score
Each category is capped, so past the cap fixing one more password leaves the total unchanged and the work looks wasted. The breakdown lists count and points per category, marks a capped deduction with (max), and explains in a tooltip why the score is standing still.
2026-08-01 19:32:37 +01:00
r-zakarya 2436825ed6 docs(tests): G2 is unreachable by design, document the health score steps
G3 invalidates the cache on every mutation, so a stale health list can no longer be staged. Also records why the score moves in steps of 10 and the per-category caps that can make early fixes invisible.
2026-08-01 19:29:40 +01:00
r-zakarya 254bbc3bca docs(tests): mark B14 validated 2026-08-01 19:19:18 +01:00
r-zakarya eaadc06daa docs(tests): mark B18 validated 2026-08-01 19:15:01 +01:00
r-zakarya dca98dde9b fix(busy): swallow the scrolling keys while a blocking operation runs
inert removes the controls from focus but not the document scroll: Space, PageUp/Down, Home/End and the arrows scroll without any focused element, so the page still moved under the overlay.
2026-08-01 19:12:36 +01:00
r-zakarya e07488e8b4 fix(busy): make the page inert so the keyboard cannot reach controls behind the overlay
The overlay covers the page, which stops the mouse but not Tab and Space. The inert attribute removes the subtree from focus, hit-testing and the accessibility tree in one go - applied to every body-level sibling except the overlay, and always cleared in hideBusy.
2026-08-01 19:09:16 +01:00
r-zakarya 4a78dc2e90 fix(hotkeys): find the native dialog by enumerating the thread windows
GetLastActivePopup returns the form itself - the common Save dialog is not an owned popup - so the code that raises it never ran. It does live on our thread with the standard #32770 class, which EnumThreadWindows finds regardless of its owner.

Logging confirmed two things worth keeping: WM_HOTKEY is dispatched during the dialog modal loop, and IsWindowEnabled detects the state correctly.
2026-08-01 19:03:03 +01:00
r-zakarya d6701a8994 fix(hotkeys): attach to the foreground thread to focus the buried dialog
The autofill helper attaches the caller to the TARGET thread, but the dialog runs on our own thread, so the attach was a no-op and Windows kept refusing the foreground change - the dialog rose without focus. Attaching to the current foreground thread instead is what grants the call.
2026-08-01 18:53:20 +01:00
r-zakarya c9c1e98470 fix(hotkeys): focus the buried dialog and say why the shortcut did nothing
SetForegroundWindow is refused when the hotkey comes from another application, so the dialog was raised but never focused; ForceForegroundWindow is now exposed by PM.Bridge for that. The explanation goes through a tray balloon because our window is disabled and behind the dialog, where no toast would be seen.
2026-08-01 18:49:03 +01:00
r-zakarya 1c0423680d fix(hotkeys): never touch the window while a native modal owns it
Ctrl+Shift+Q and Ctrl+Shift+A call RestoreWindowFromTray before JS can refuse, so a JS guard is too late by construction. Restoring the owner of a Save As dialog stranded it behind everything and the app looked frozen while it was only disabled.

NativeModalActive tests IsWindowEnabled - Windows disables the owner for the lifetime of the modal - and brings the dialog back to front via GetLastActivePopup instead of merely refusing.
2026-08-01 18:40:45 +01:00
r-zakarya 42d2d6fda5 fix(autofill): refusing a request must not move any window
The busy guard cancelled through the path that hands the foreground back to the captured target. During an export that pushed the native Save dialog behind the other application, leaving the app disabled by a modal the user could no longer find. refocus=0 separates refusing a request from ending a user interaction.
2026-08-01 18:30:00 +01:00
r-zakarya 03cc7adc25 fix(autofill): refuse a fill while a blocking operation runs
Guarded in autofillHandleRequest, upstream of the single-match versus picker split, so it covers Ctrl+Shift+L/P/T and both outcomes: the picker would open behind the overlay, and a single-match fill would minimise our window mid-operation. Cancels the request so Delphi drops the captured target HWND.
2026-08-01 18:04:35 +01:00
r-zakarya be629b2bcf fix(hotkeys): block the editor and quick search while a blocking operation runs
Global hotkeys come from Win32, outside the window, so the busy overlay intercepts nothing: Ctrl+Shift+A opened the editor behind it, unreachable. Guarded in openSlideOver and openQuickSearchModal rather than at each trigger - every caller routes through them, and no save path raises the overlay.
2026-08-01 18:02:20 +01:00
r-zakarya 6bb64832ef fix(editor): guard the second Esc path against the busy overlay too
There are two Escape handlers: a capture-phase one for the editor and the global one-surface-per-keystroke chain. Only the first was guarded, so export - which reaches the editor through the global chain - still popped the discard-confirm under the overlay.
2026-08-01 17:35:04 +01:00
r-zakarya 5d10935ae2 fix(editor): ignore Esc while a blocking operation owns the screen
The discard-confirm opened underneath the busy overlay, which sits at z-index 200 - blurred and half-clickable. Worse than the display: it let the user arbitrate closing the editor while a sync or a rotation was writing to the same entry.
2026-08-01 16:49:09 +01:00
r-zakarya d8a1728371 docs(tests): mark A13b validated 2026-08-01 16:45:48 +01:00
r-zakarya bd86899c2b feat(entries): offer to save the editor content as a new entry when the row is gone
A 404 on save meant the work had to be copied out by hand. The POST mints a fresh uuid, so this is a new entry rather than a resurrection and cannot fight the tombstone the delete left behind. Gated on status 404 AND the server message: the HTTP layer answers 404 to any request it refuses, which the status alone would misread.
2026-08-01 16:41:11 +01:00
r-zakarya 5eb279ff31 fix(entries): 404 on PUT when the row no longer exists
The UPDATE matched nothing but the handler still answered 200 Updated, so the editor reported Saved for an edit that went nowhere - the entry had been deleted by a sync or a purge after the editor opened it. Silent data loss; the client already surfaces a failed save as an error toast.
2026-08-01 16:20:45 +01:00
r-zakarya 7158664b0d docs(tests): replace a stray image reference in A12b with its check mark 2026-08-01 16:06:11 +01:00
r-zakarya 2579264285 docs(tests): F6 fully validated, sync round-trip included 2026-08-01 15:56:14 +01:00
r-zakarya 9b32ae407e docs(tests): add F7 for the sync last-write-wins arbitration
Records the two traps that invalidate the test: any PUT rebumps updated_at, undoing a manual rollback, and created_at plays no part in the arbitration.
2026-08-01 15:31:32 +01:00
r-zakarya e919a79522 docs(claude): document the multiline Notes field and the custom-field row 2026-08-01 14:00:45 +01:00
r-zakarya 4936aa78ca refactor(entries): drop the cyan secret marker on the reveal eye
The eye only renders on a secret field, so its presence already carries the state.
2026-08-01 12:47:59 +01:00
r-zakarya a8c1a88ec4 refactor(entries): drop the inline copy button, the row menu carries it
Row is down to label | value (eye inside) | menu.
2026-08-01 12:05:44 +01:00
r-zakarya e5758f984c fix(entries): keep the reveal eye clear of the multiline scrollbar
scrollbar-gutter reserves the track whether or not it is showing, so the eye keeps the same position once the content passes the auto-grow cap.
2026-08-01 12:04:46 +01:00
r-zakarya a2cd08c676 feat(entries): move the reveal eye inside the custom-field value
Same placement as the TOTP field, which buys back a column on a dense row: label | value (eye inside) | copy | menu. Pinned top-right on a multiline field so it stays clear of the resize grip.
2026-08-01 11:57:56 +01:00
r-zakarya c71ea643e7 feat(entries): add Copy value to the custom-field row menu
The inline copy button stays: copy is the frequent action and burying it costs a click every time. Unlike the old lock-versus-eye pair, two ways to copy are not confusable.
2026-08-01 11:55:29 +01:00
r-zakarya a0a069c7f6 refactor(entries): fold the secret toggle into a per-field row menu
The lock button sat next to the eye and both masked the value, so they read as the same control. The eye now carries the state - cyan ring when the field is stored as secret - and flipping the flag moved into a row menu alongside Remove field, one deliberate step away from the everyday reveal. The row drops from six columns to five.

The menu reuses the card kebab classes, so closing it on an outside click is already handled.
2026-08-01 11:47:25 +01:00
r-zakarya 6705a8b478 fix(entries): toggle the secret flag in place instead of rebuilding every row
rerender() wipes the whole custom-fields list, which reads as the panel flashing and also drops focus and the auto-sized height of a multiline field.
2026-08-01 11:06:59 +01:00
r-zakarya 4b7f5ff599 docs(entries): spell out the difference between the reveal eye and the secret lock
Both toggles mask the value, so they read as duplicates. Tooltips now say which one is temporary and which one changes what is stored.
2026-08-01 10:34:44 +01:00
r-zakarya 9dc9f625d6 fix(entries): size the multiline field from its content instead of a fixed height
A manual resize was lost on reopen: the row is rebuilt on every open, and remembering the height would not survive a restart either since localStorage is wiped when the ephemeral port changes the origin. Deriving it from the content removes the state entirely - the field is always the right size.
2026-07-31 18:31:26 +01:00
r-zakarya 2cab20eb76 docs(tests): add F6 for the multiline Notes field 2026-07-28 23:21:21 +01:00
r-zakarya d8643b96d1 feat(entries): multiline Notes field for any entry, masked by default
The note body of a note entry is the encrypted_password column, so logins had no way to carry notes. Rather than a dedicated encrypted column and the seven call sites that come with it, this rides on custom_fields, which rotation, duplicate, export/import and sync already carry.

Both the open-time normalisation and the save-time rebuild drop any key they do not name, so multiline is carried explicitly in each - otherwise the field silently reopens single-line.
2026-07-28 23:06:39 +01:00
r-zakarya 415dc6c8ba docs(claude): record that pre-login settings must live in DPAPI prefs 2026-07-28 22:51:00 +01:00
r-zakarya 67107fba37 fix(unlock): persist unlockMode in DPAPI so a cold start still asks for the PIN
unlockMode lived only in localStorage, wiped on every launch since the ephemeral port changes the origin, and in settings_json, which needs a session to read. The auth screen therefore always fell back to the master password on a cold start even though the PIN blob was there. Mirror it to a DPAPI pref and read it back in showAuth, like rememberedUsername.
2026-07-28 22:50:28 +01:00
r-zakarya 52af21f917 docs(tests): mark B1-B13 validated 2026-07-28 20:07:49 +01:00
r-zakarya 83a0e08df8 docs(tests): fix B6, a trashed entry can never be open in the editor
openTrashActions is an empty stub and the trash kebab has no Edit item, so the scenario as written was not reachable. Rewritten around what is: the purge must leave an unrelated open editor alone, plus the one remote path that can actually trash an entry while it is being edited.
2026-07-28 19:54:37 +01:00
r-zakarya 4499c8c570 docs(tests): mark H11/H12 validated 2026-07-28 19:44:13 +01:00
r-zakarya 80711272c4 fix(tray): a -tray launch does take the foreground, so give it back
FMX shows its taskbar proxy before we hide it, so the launching console was left inactive. GStartupForeground, captured in the unit initialization before any window of ours exists, holds it. A Windows-startup launch is unaffected: the foreground is a shell window there, which the capture filters out.
2026-07-28 19:38:02 +01:00
r-zakarya 7c08a69bb2 docs(tests): mark H13 validated, Ctrl+Shift+P confirmed on Reddit 2026-07-28 15:50:25 +01:00
r-zakarya 16577a22be docs(tests): record why a full fill cannot work on the Reddit login form 2026-07-28 15:48:49 +01:00
r-zakarya 38ba208455 fix(autofill): stop sending Ctrl+A after the Tab in a full fill
Tabbing into an input already selects its whole content, so the second clear was redundant. Where Tab lands on something that is not editable - a link, a reveal button, the document, as on the Reddit login form - Ctrl+A selected the entire page instead and the password went nowhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:32:25 +01:00
r-zakarya 40e2054496 docs(tests): add H11/H12 for the foreground-handback guards 2026-07-27 23:17:52 +01:00
r-zakarya 2a1da8913b fix(tray): do not un-minimise the application we hand the foreground back to
ForceForegroundWindow restores an iconic target, so closing to the tray would pop back open a window the user had deliberately minimised. Skip the handback in that case and leave the choice to Windows. A -tray launch also skips it: it never took the foreground from anyone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:14:12 +01:00
r-zakarya 33d8df3dee fix(tray): give the foreground back to the application we took it from
Hiding a window hands the foreground to nobody, so we stayed foreground while
invisible. The next hotkey then captured our own window as its target and typed
the password into it, and Ctrl+Shift+A opened a New entry with an empty title —
the guard correctly refusing to prefill with "Password Manager".

Track foreground changes with an EVENT_SYSTEM_FOREGROUND hook and keep the last
window that is neither ours nor a shell one, then reactivate it just before
hiding. Sampling on demand cannot work: by then the user's application is two
foreground changes behind (their app, the taskbar they clicked, us), and
reactivating after the hide is silently refused since only the current
foreground owner may give focus away.

Autofill paths pass AReturnForeground=False — they already placed the
foreground on the window they filled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 23:10:22 +01:00
r-zakarya a9e56c52b5 fix(autofill): send the window back to the tray when the picker is dismissed
Cancelling left the window on the desktop, though it had only been restored to
ask which entry to fill. Quick-search already hides itself on Esc; this makes
the picker symmetric. Applies to all three hotkeys — the capture and the hide
both sit on the shared BridgeAutofillRequest / autofill/cancel paths, so full,
password-only and TOTP behave identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:11:43 +01:00
r-zakarya 3c84bdca49 fix(autofill): return to the tray after picking in the multi-match picker
Only the quick-search path captured whether the window was tray-hidden and fed
it back as hide_after; JS never sends that parameter from the autofill picker,
so Ctrl+Shift+L pulled the window out of the tray and left it on the desktop.

Capture it in BridgeAutofillRequest instead, when the hotkey fires — by execute
time the picker has already restored the window and the state is gone. Same
rule as quick-search: tray-hidden only, a taskbar-minimised window is not sent
to the tray it never came from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:03:57 +01:00
r-zakarya e98acabbbd revert(winstate): drop the parked restored-size rect, it left a stub window
Widening rcNormalPosition while the window was down did remove the restore
flash, but SetWindowPlacement left a small stub frame in a corner of the screen
whenever the window was maximised — during the minimise animation and equally
once the window was already iconic. A permanent visible artefact is worse than
an ugly animation, so this goes back to the validated behaviour: correct final
state, flash still present.

The flash itself is an FMX bug, reproducible in an empty FMX app. Rationale and
the five rejected approaches are recorded in TEST_SCENARIOS.md I1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:51:28 +01:00
r-zakarya 17e817f8ab fix(winstate): park the restored-size rect so the FMX restore has nothing to grow
FMX drops the maximised state when Windows minimises the form externally —
reproducible in an empty FMX app. Windows then picks rcNormalPosition as the
restore target before it animates, so every reactive correction measured (queued,
inline in MINIMIZEEND, inside the restore's own WM_SIZE) landed after the frame
was already growing: the state ended correct and the flash remained.

Widen rcNormalPosition to the monitor work area while the window is down instead.
FMX still restores to normal, except normal is now full-screen, so nothing grows;
ReleaseNormalRect writes the real rectangle back once the window is maximised
again, keeping the user's own restored size intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 20:13:52 +01:00
r-zakarya b52cdc2d0b fix(winstate): apply the wanted window state inline, killing the restore flash
The MINIMIZEEND correction was posted through ForceQueue. Instrumenting the
hook showed the window state identical at the hook and one message-loop turn
later, so the queue guarded no race and only bought the normal-sized frame the
user saw. ApplyWantedWindowState now runs inline; the deferred pass stays as an
idempotent net.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:39:23 +01:00
r-zakarya 99b68c3e67 docs(startup): correct the rationale for the deferred server start
The comment justified deferring btnStartClick by the taskbar-proxy exposure
window, which the .dpr fix has since removed. Kept as-is per decision — it
works and reaching the tray sooner is desirable — but the comment now says
that, so nobody "fixes" it later against a premise that no longer holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:18:19 +01:00
r-zakarya ca23b88582 docs: section L — Windows startup (--tray)
L1 hidden start with no taskbar button, L2 the button coming back on restore,
L3 the normal launch (validated). Records why every fix placed inside the form
was powerless: the FMX proxy is shown before FormCreate is reached.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:12:31 +01:00
r-zakarya 8d03b74b6a chore: drop the -tray debugger run parameter from the project file
Committed by accident while testing the tray launch. Left in, every F9 from
the IDE would start hidden in the tray — the exact confusion the parameter is
worth warning about. It is a local debugging preference, not project config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:09:29 +01:00
r-zakarya 04f52851fc fix(startup): use ApplicationHWND + WS_EX_TOOLWINDOW, restore it on show
Adopts the approach that actually works: ApplicationHWND (FMX.Platform.Win) IS
the taskbar proxy, so there is nothing to hunt for and it is valid before the
form exists. And hiding it was never enough — the taskbar had already drawn
the button; clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW is what
removes it, since a tool window is never listed.

That style is permanent, which is why the button never came back after a
restore. TrayProxyShowInTaskbar puts it back, called from
RestoreWindowFromTray — the single choke point every tray restore already goes
through — so it lands before RestoreFromTray shows the windows and the style
takes effect on that show.

Removes the scaffolding this chase accumulated and that the real fix makes
pointless: the EVENT_OBJECT_SHOW hook, the polling worker, FindOurFMXAppWindow,
TrayHideNow with its four probes, the one-shot OnActivate hide, and the [tray]
tracing. ~4.8 KB of dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:06:19 +01:00
r-zakarya ab8186ef75 fix(startup): start the proxy watcher from the .dpr, before the form exists
Decisive observation: the taskbar button is already up before FormCreate is
entered. Everything I had tried lived inside the form, so all of it ran after
the button was drawn — the watcher included, which is why moving it around
never helped.

StartTrayProxyHider is now exported from UMainForm and called from the .dpr
right after Application.Initialize, before CreateForm. It polls every 5 ms and
hides our TFMAppClass proxy unconditionally (the button can be up before
WS_VISIBLE reads as set, which the previous IsWindowVisible check would have
skipped), stopping as soon as the tray startup completes so a user restoring
from the tray is never fought.

Raw Win32 only — FindWindowEx + ShowWindow, no FMX object touched at that
point, which is what made the earlier .dpr attempt crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:15:32 +01:00
r-zakarya 4f2aff9de4 fix(startup): hide the taskbar proxy from a worker thread
Why every previous attempt could not work: the main thread is BUSY inside
FormCreate and pumps no message, and that is exactly when FMX creates and
shows its taskbar proxy. A WinEvent hook, a timer, a queued call — none of
them can run in that window. Deferring the server start shortened the window
but did not remove it, because the proxy is created regardless.

A worker thread is the one thing still running while the main thread blocks.
It polls for our TFMAppClass window (matched on our own process id) and hides
it the instant it appears; ShowWindow is safe cross-thread. It stops as soon
as the tray startup completes, with a 10 s ceiling so it can never linger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:37:51 +01:00
r-zakarya 17e48aa688 fix(startup): start the server AFTER the tray hide, not before
The visible thing is only the taskbar button — the form window is never shown
(form_visible_was=False throughout). That button belongs to the FMX proxy,
created partway through FormCreate, and FormCreate runs for ~1 s because it
opens the DB, binds the Indy server and kicks off WebView2. The thread pumps
no message during that second, so nothing could hide the proxy until it ended:
hooks and hide points alike were powerless by construction.

So stop trying to react faster and remove the wait instead: on a --tray launch
btnStartClick now runs inside the deferred block, after MinimizeToTray.
FormCreate returns in milliseconds, the proxy is hidden immediately, and the
server starts once nothing is on screen. FServer is constructed earlier
(line 295), so FServer.Active stays safe meanwhile. Normal launches keep the
original order untouched.

The EVENT_OBJECT_SHOW hook stays armed across the deferred start, so a proxy
re-shown during it is hidden again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:33:26 +01:00
r-zakarya 8fa956f07f fix(startup): hide the FMX taskbar proxy the instant it is created
The instrumented run pinned it down: the FORM is never visible
(form_visible_was=False at all four hide points). What shows is the FMX
taskbar proxy — app=0 at 20.145, app=854682 at 20.974 — so it is created
partway through FormCreate and stayed on screen for the best part of a second
until the first hide that could see it.

A WinEvent hook on EVENT_OBJECT_SHOW, active only for the --tray startup,
hides any top-level window of ours the moment Windows shows it, and is torn
down once MinimizeToTray has run.

Two details that would each have broken it:
- WINEVENT_INCONTEXT, not OUTOFCONTEXT: the latter queues the callback until
  the thread pumps messages, which does not happen during FormCreate — exactly
  when the proxy appears.
- The pending flag is a unit-level var, not a form field: CreateForm assigns
  the MainForm global only after the constructor returns, so a callback firing
  during FormCreate could not have reached the form.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:28:29 +01:00
r-zakarya 283676224f fix(startup): hide at three points during FormCreate, and prove when it shows
The timeline says FormCreate itself runs for 1.02 s (45.509 → 46.532) while
the hide only landed at 46.663 — so if the window is visible during that
stretch, it accounts for exactly the second being reported.

TrayHideNow() hides the form AND the FMX taskbar proxy, and is now called at
three points: top of FormCreate, right after the browser is parented (which
forces the handle to exist, the likely moment FMX shows it), and at the end of
FormCreate — plus the existing on-activate one-shot.

It also logs whether the window WAS visible each time, which pins down exactly
where FMX turns it on instead of another guess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:53:22 +01:00
r-zakarya 0506d757dd fix(startup): hide the taskbar proxy too, and trace the --tray timeline
The window still showed, taskbar entry included. Two things.

The taskbar button belongs to the FMX proxy window (TFMAppClass), a separate
top-level window from the form — hiding the form alone never removed it.
FindFMXAppWindow moves from private to public so the activate-hide can drop
both. (Guarded on Assigned(FBridge): the handler is installed at line 222,
the bridge is created at 268.)

And rather than guess a fourth time at FMX's startup order — the same trap as
the window-state saga, which only fell to instrumentation — this logs a
millisecond timeline: FormCreate begin/end, the activate-hide, and the
deferred MinimizeToTray. Whichever gap holds the second will be obvious.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:47:25 +01:00
r-zakarya eec7bc4f80 fix(startup): revert the .dpr hide (AV) and hide on first activate instead
The .dpr version crashed with an EAccessViolation writing to 000000CE —
touching MainForm.Visible/Handle between CreateForm and Run is evidently too
early for FMX. Reverted; the app builds and runs as before.

Replaced with a one-shot OnActivate handler installed in FormCreate only for
--tray: it fires the instant FMX shows the form, hides it (FMX + Win32) and
unhooks itself, so it can never fight the user later. That runs far earlier
than the deferred MinimizeToTray, which waits for the message loop and hence
for the DB open, server start and WebView2 init — the second the window used
to spend on screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 05:42:04 +01:00
r-zakarya 169cdddf9d fix(startup): hide the --tray window from the .dpr, where it sticks
Setting Visible := False in FormCreate wasn't enough — FMX re-shows the main
form after the constructor returns, so the window still appeared for ~1 s
during the Windows logon.

Do it in the .dpr instead, between CreateForm and Run: that is after FMX's own
show and before any message is pumped, so nothing ever paints. Backed by a
Win32 ShowWindow(SW_HIDE) so it holds whatever FMX decides. The handle is
still created, so WebView2 initialises normally and the vault is ready as soon
as the user clicks the tray icon.

The taskbar entry can still appear briefly; the deferred MinimizeToTray drops
it, along with adding the tray icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 05:35:58 +01:00
r-zakarya e6a749465e fix(startup): --tray launch no longer flashes the window
With "Start with Windows" the window sat on screen for a second or two before
dropping to the tray. The hide was deferred with ForceQueue, which only runs
once the message loop is free — i.e. after the DB open, the HTTP server start
and the WebView2 init, all of which happen first.

Set Visible := False at the very top of FormCreate instead, before any of that
work, so there is nothing to show in the first place. The deferred
MinimizeToTray stays: it still adds the tray icon and drops the taskbar entry,
and doubles as the safety net if FMX shows the main form regardless — worst
case the behaviour is exactly what it is today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 05:29:01 +01:00
r-zakarya 087eb52d66 docs: F1b validated — overwrite restores the flags
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:27:40 +01:00
r-zakarya 220710174f docs: split F1 into F1a (delete + reimport) and F1b (overwrite)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:25:04 +01:00
r-zakarya af234ce049 fix(import): overwrite mode restores favorite and pinned too
F1 passed when the entry had been deleted first (INSERT path) but not in
overwrite mode: that path uses PUT, and PUT deliberately ignores
favorite/pinned so a SYNC update can never clobber the local flags — they are
device-local by design. A restore, however, must bring them back.

The overwrite loop now applies them through the dedicated toggle endpoints,
and only when they differ from the local entry. JS-only: PUT keeps ignoring
them, so sync behaviour is untouched.

Splits the F1 scenario into F1a (delete + reimport, already green) and F1b
(overwrite).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:24:38 +01:00
r-zakarya f198eaf6f3 docs: K1 — the clear-field toggle vs Tab-selects-all, intentional
Records why disabling "Clear the field before typing" still leaves the
password replaced: Tab selects the target field's content, so the keystrokes
overwrite it. Our code clears nothing — verified from the JS clear=0 through
both ClearFieldIfWanted guards. The toggle exists to avoid sending Ctrl+A
(terminals, remote desktops), not to preserve field content.

Filed as intentional so it isn't "fixed" later into appending a new password
onto the old one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:05:49 +01:00
r-zakarya e68536420f docs: J2-J4 validated — fill, copy and already-visible paths
Section J fully green: no double minimise on the fill path, the clipboard
survives the copy path, and an app already on screen is not sent to the tray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:58:10 +01:00
r-zakarya 59090dcf67 docs: section J — quick search from the tray
J1 covers both bugs just fixed (lost keyboard focus after the tray restore,
and the coalesced cmd:// that swallowed the minimise). J2/J3 check the fill and
copy paths still return to the tray exactly once, J4 that an app which was
already on screen is not sent to the tray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:39:56 +01:00
r-zakarya e88dbf3ec8 fix(quick-search): Esc from the tray now closes AND returns to the tray
Two bugs in the same flow, both on the tray-originated path.

1. Esc did nothing until the search box was clicked: after the tray restore
   the WebView2 child does not hold the OS keyboard focus, so no keystroke
   reached the page at all — a DOM .focus() cannot fix that. Both quick-search
   entry points now call WebBrowser.SetFocus after restoring.

2. Esc closed the modal but left the app on screen. In fill mode
   closeQuickSearchModal fires cancelAutofill and then minimizeToTray, and two
   back-to-back window.location.href assignments COALESCE — the minimise was
   dropped. The copy path was unaffected because it never calls cancelAutofill,
   which is why this only showed up here. Deferred by a setTimeout(0), the same
   remedy CLAUDE.md already documents for quickunlock/store + pin/clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:25:28 +01:00
r-zakarya 610755e8e6 docs: I5/I6 — minimise-guard interaction with autofill and the tray
I5 exercises the path the guard fix addresses (autofill minimises, then the
window is hidden to the tray, so MINIMIZEEND may never arrive). I6 is the
non-regression check on the tray and focus paths the hook could have touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:08:09 +01:00
r-zakarya 2773cc444b fix(window): route every tray restore through one guard-clearing helper
Checking the hook's blast radius surfaced a real interaction: ExecuteAutofill
genuinely minimises the window (SW_MINIMIZE) before typing, which raises the
minimise guard. If the window is then HIDDEN to the tray rather than
un-minimised — the quick-search hide-after flow does exactly that —
MINIMIZEEND may never fire and the guard would stay raised for the rest of the
session, silently freezing the tracked window state.

All six FBridge.RestoreFromTray call sites now go through
RestoreWindowFromTray, which clears the guard first. RestoreFromTray replays
its own saved placement, so nothing is left for the guard to protect there.

MinimizeToTray itself is unaffected: it uses SW_HIDE, which raises no minimise
event at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:04:37 +01:00
r-zakarya 7ae20aac43 docs: section I — window maximised-state scenarios
I1/I2 validated (both directions survive Show desktop), plus the tray path and
how to read the [winstate] diagnostics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:55:55 +01:00
r-zakarya 63dd7a296a chore(window): trim the winstate diagnostics now the fix is confirmed
The traces did their job: hwnd=657868 vs form=657842 proved Win+D minimises
the FMX taskbar proxy, not the form — which is why the earlier HWND filter
swallowed every event. Two full cycles then showed minimise start/end firing
and the re-maximise landing, with no stray "user resize" during the restore.

Keeps the two lines worth having: the hook install result (a silent failure
would be baffling) and the correction itself, which is rare.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:54:30 +01:00
r-zakarya 72fe01f513 fix(window): don't filter the hook on the form HWND
The hook installed but never fired: an FMX app owns TWO top-level windows —
the form and the taskbar proxy (the one FindFMXAppWindow digs out in
PM.Bridge) — and Win+D minimises the proxy. Comparing the event HWND against
the form handle rejected every event.

Dropped the comparison; the hook is already scoped to our process and WebView2
lives in separate ones, so any window event arriving here is ours. Logs the
install result and each event's HWND so a silent failure is visible next time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:50:26 +01:00
r-zakarya 7a19bdc13b fix(window): observe the minimise itself via a WinEvent hook
Third discriminator measured and rejected: the log shows the restore's resize
arrives with fg=True (22:44:08), so foreground ownership no more separates it
from a user gesture than the placement flags did.

FMX simply exposes nothing usable — no resize while iconic, and the restore's
resize is indistinguishable from the user's own. So stop inferring and observe
the transition at the OS level: SetWinEventHook on EVENT_SYSTEM_MINIMIZESTART
/ MINIMIZEEND, scoped to our process, WINEVENT_OUTOFCONTEXT (no injection,
callback on our thread).

Minimise start raises a guard that makes the resize handler ignore everything
until the restore settles; minimise end re-applies the state the user actually
left, in both directions. Hook failure is non-fatal — only the preservation is
lost. Unhooked in FormDestroy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:47:52 +01:00
r-zakarya ca54c39c75 fix(window): use foreground ownership to tell a user resize from a restore
The log settled the previous attempt: WPF_RESTORETOMAXIMIZED (flags=2) is set
for ANY maximised window, not just one on its way back from minimised, and
showCmd is already 1/3 by the time the resize fires. Testing those classified
the user's own Maximize click as a restore, so the intent was never recorded
(want stayed False) and nothing could be re-applied afterwards.

Windows exposes no restore marker here. What does separate the two: a user
gesture happens while the window OWNS THE FOREGROUND; FMX's re-layout during a
restore (and during the startup burst) runs before it does. Resizes that
arrive without foreground ownership are no longer recorded as intent, and
re-apply a maximised state the user had left.

Placement is still read, for the log only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:42:05 +01:00
r-zakarya 03c638b2e1 fix(window): ask Windows whether a resize is part of a restore
The traces killed the previous discriminator: OnActivate fires constantly
(WebView2 focus churn) and even right after the user clicks Maximize, so
"resize followed by activate" wrongly classified a user maximise as a restore
and dropped the intent.

Windows itself knows: WPF_RESTORETOMAXIMIZED (or showCmd still
SW_SHOWMINIMIZED) marks the resize of a window on its way back from minimised.
That resize is now never recorded as intent, and triggers the re-maximise
directly — no dependency on activate ordering. Activate is demoted to a
guarded safety net.

Log line now dumps zoomed/showCmd/flags so the next run confirms the flag is
actually set on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:22:09 +01:00
r-zakarya 28f0d1c5bc fix(window): let an activate cancel the restore's own resize
The traces settled it: on a restore FMX fires its resize BEFORE the activate,
already reporting "not maximised" — so any snapshot taken at activate time was
too late. A genuine user resize, by contrast, is never followed by an activate
(the window is already active).

That is the discriminator. A resize no longer commits immediately: it queues,
and an activate arriving first cancels it, leaving the intent intact for the
correction to re-apply. Verified against every sequence in the log, including
the startup burst (three resizes then an activate), which must NOT be mistaken
for the user un-maximising.

Skips resizes that don't change the maximised state, so dragging the window
edge no longer floods the queue or the log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:16:47 +01:00
r-zakarya ab12f6bf66 fix(window): apply the restore correction after FMX finishes, with tracing
The FWasMinimized gate never armed — FMX evidently fires no resize while the
window is iconic — so activate bailed out and the maximised window still came
back normal.

Stop depending on that signal or on event ordering: snapshot the intent at
activate (before a restore-time resize can overwrite it) and apply the
correction from a queued call, which necessarily runs after FMX has finished
sizing the window. FRestoring keeps that resize out of the tracking, and the
intent is re-asserted afterwards since our own ShowWindow fires one too.

Adds [winstate] LogLine traces so the next iteration works from observed
event order instead of guesses (needs config.txt with debug=true).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:13:30 +01:00
r-zakarya 8204ccb316 fix(window): stop forcing maximised on a window the user left normal
The previous version over-corrected: a normal window came back maximised
after Win+D. Two causes, both fixed.

- It read FMX's WindowState, which does not reliably follow a native
  un-maximise, so FWantMaximized never cleared. Now reads Win32 IsZoomed /
  IsIconic, which is the actual truth.
- The restore fires its own resize, reporting whatever FMX decided — recording
  that erased the very intent we were preserving. FWasMinimized now gates the
  restore's resize out of the tracking.

Activate then corrects both directions: re-maximise when FMX dropped a
maximised window, restore when Windows brought a normal one back maximised.
Degrades safely — if the minimise resize never fires, it simply does nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 20:06:34 +01:00
r-zakarya 03a8a6f8a6 fix(window): re-apply maximised after an external minimise (Win+D)
The earlier fix only covered the tray restore path, but Show desktop /
taskbar restore never runs our code at all — it is pure Windows + FMX, and
FMX brings the window back "normal" after forgetting it was maximised.

Track the state the user actually settled on (OnResize, ignoring wsMinimized
since that is the state we must survive) and re-apply it on OnActivate, which
fires however the user returns: taskbar, tray or Alt+Tab. Gated on
FWantMaximized so a window the user deliberately un-maximised is left alone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:58:27 +01:00
r-zakarya 6bed75f49e fix(window): a maximised window comes back maximised after Show desktop
Win+D / Show desktop minimises the window without any tray transition, so the
restore took the non-tray branch. FMainForm.Show runs first there and
re-applies FMX's own WindowState (normal), discarding the
WPF_RESTORETOMAXIMIZED flag Windows had recorded — the app returned in normal
size.

Read the placement flag BEFORE showing, and re-apply SW_SHOWMAXIMIZED when it
was set. The tray path is untouched: it already replays its own captured
placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 19:50:24 +01:00
r-zakarya f45a17d876 fix(hotkeys): name the full combo when its key-up reaches us
The message could only cite the modifiers ("after Ctrl+Shift") because
Windows consumes the KEYDOWN of a combo it routed to another app. Its KEYUP
often still arrives though — when it does, report the exact combo the user
tried: "Ctrl+Shift+T is already used by another app".

Falls back to the modifiers-only wording when the key-up is swallowed too.
Can't misfire: an accepted combo ends the capture on keydown, so a non-modifier
key-up during capture means that key was swallowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:40:08 +01:00
r-zakarya 6518b59860 docs: H8 — capturing a combo owned by another app (validated)
Covers the swallowed-combo detection, the abandoned-capture timeout, and the
internal collision check that only works now that the capture releases our
own hotkeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:38:26 +01:00
r-zakarya 1bcb74b0bd fix(hotkeys): detect a swallowed combo on key release, not after 10 s
"No combo captured" was both slow and wrong: the user HAD pressed a combo.
We actually receive the Ctrl/Shift/Alt keydowns — only the final key of a
combo owned by another app is swallowed by Windows. So "modifiers held, then
all released, no key ever arrived" pinpoints the situation exactly.

Reported on keyup, instantly, naming what was held: "No key registered after
Ctrl+Shift. If you did press one, another app already owns that combo — pick
a different one." The wording also holds if the user just tapped a modifier
and let go.

The 10 s timer stays as the fallback for a capture armed and abandoned with
no key at all (it would otherwise swallow every keystroke until Esc), with a
message that no longer contradicts the user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:28:31 +01:00
r-zakarya c818f12c24 fix(hotkeys): release our combos while capturing a new one
A RegisterHotKey combo is delivered to the owning window, never to the
WebView. So a combo one of OUR slots holds could not be captured at all —
pressing it fired that hotkey instead (Ctrl+Shift+L ran an autofill in the
middle of the capture), and the "already used by another hotkey" collision
check was unreachable dead code. Swapping two combos was impossible.

The capture now suspends all five hotkeys (enabled=0 + vk=0 for quick-search
and new-entry, which unregisters without re-registering and is excluded from
the failure report) and restores them on finish — silently when cancelled or
timed out, with the usual toast on a successful change.

Combos held by ANOTHER app still can't be captured — that one is out of our
hands, and the 10 s timeout explains it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:22:36 +01:00
r-zakarya dd69747cb8 fix(hotkeys): capture times out instead of waiting forever
A combo another app already registered is consumed by Windows and delivered
to that app — our keydown listener never fires. The capture therefore sat on
"Press combo…" indefinitely with no feedback, which is exactly what a user
sees when they try to re-assign a taken combo.

Worse, while armed the capture preventDefaults every keystroke, so an
abandoned capture froze the app's keyboard until Esc.

10 s timeout that restores the button and explains the likely cause: the
combo never reached us because another app owns it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:15:51 +01:00
r-zakarya 3513e7b6e5 fix(ui): actionable toasts linger, and a dead hotkey shows in the sidebar
Every toast auto-dismissed after 2.8 s — fine for a success receipt, too
short to read AND act on an error. Errors and warnings now stay 10 s and can
be clicked away; success is unchanged.

The hotkey-conflict warning also needed a trace outside the toast: the ⚠ in
Settings only helps someone who opens the panel. A dot on the sidebar Settings
entry (plus a tooltip) is now the cue in the main UI that a combo is dead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:10:17 +01:00
r-zakarya feb956001e docs: H7 validated — persistent conflict marker confirmed
Also records the test pitfall: hook-based apps swallow the key without
failing RegisterHotKey, so they produce a dead hotkey with no marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 18:06:46 +01:00
r-zakarya 4965ff2518 feat(hotkeys): persistent conflict marker in Settings
A toast is gone by the time the user opens Settings, so a hotkey Windows
refused looked perfectly configured while doing nothing. The offending row now
carries a ⚠ marker (amber border + tooltip explaining the combo is taken)
until a re-registration clears it.

Delphi reports slot ids instead of labels; HOTKEY_SLOTS maps id → button +
label in one place, so JS owns the wording and can flag the exact row. The
markers are re-applied by openSettings, surviving panel re-renders.

Also stop toasting success on the startup push (it fires on every unlock) —
autofillPushHotkeys(true). A clash still warns: that one is actionable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:52:05 +01:00
r-zakarya 3d3ab3b02b fix(hotkeys): name the combo another app already owns
Two gaps when Windows refuses a RegisterHotKey:
- the toast said "one or both hotkeys" — wrong since there are three autofill
  combos, and it never said which one lost.
- Quick search and New entry failures were only written to the debug panel,
  which is off in production: the hotkey silently did nothing with no clue why.

Delphi now exposes the per-slot registration outcome and the host accumulates
the failed combo names across all five hotkeys, emitting one notification for
the batch. A combo the user deliberately cleared (vk=0) is never listed.
JS names them: "Already used by another app: 2FA code — pick another combo in
Settings".

Registration stays best-effort per slot: one clash doesn't disarm the others.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:46:33 +01:00
r-zakarya 4c745d2dd9 docs: section H — 2FA hotkey scenarios
H1 fill (confirmed working), H2 entry without TOTP, H3 picker eligibility,
H4 configurable combo + collision + reset, H5 locked vault, H6 code freshness
at period boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:42:27 +01:00
r-zakarya 7c86f35fce feat(autofill): Ctrl+Shift+T types the 2FA code
Same workflow as the password-only hotkey: click the 2FA field, press the
combo, the current 6-digit code is typed in.

Reuses the whole autofill pipeline — no new native typing path. The code is
sent in the password slot with an empty username, so Delphi's existing
password-only branch types just the code.

- Bridge: akTotp kind, hotkey id 47, registered/torn down alongside the other
  two autofill combos; an unconfigured combo (vk=0) doesn't count as a
  registration failure.
- Matching filters on totp_secret BEFORE scoring, so the multi-match picker
  never offers an entry that has no code.
- Configurable + synced like the others (autofillHotkeyTotp), collision-checked
  against the other four slots, covered by Reset to defaults and the cheatsheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:53:00 +01:00
r-zakarya 2d6ba98073 docs: G1 also covers the deferred-focus steal on Fix
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:44:06 +01:00
r-zakarya 47689f693c fix(health): Fix keeps focus on the password field
openSlideOver defers a focus+select on the title (setTimeout 80ms), which
landed after openEntryForFix's own focus and yanked the caret out of the
password field a moment after Fix was clicked.

Pass the existing { noFocus: true } option — added for the post-save re-open,
exactly the same need: the caller owns the focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:40:30 +01:00
r-zakarya 0184fc7cc2 docs: section G — Vault health scenarios (Fix button, cache freshness)
G1 (cancelled switch must not act on the still-open entry), G2 (Fix on an
entry deleted meanwhile), G3 (dashboard reflects import/sync/delete without
a manual Recompute).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:32:05 +01:00
r-zakarya d40fd57133 fix(health): invalidate the dashboard cache whenever entries change
healthCache was only dropped on lock, on save and by the Recompute button.
Import, sync, delete, batch ops, duplicate, restore and the master-password
rotation all left it in place, so Vault health kept showing a pre-mutation
score and listed entries that no longer exist.

Invalidate inside loadEntries() — the choke point every mutation path already
goes through — instead of one opt-in per caller. Recompute stays lazy: it only
runs when the dashboard is next rendered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:31:29 +01:00
r-zakarya 8b02a5650a fix(health): Fix button must not act when the switch was cancelled
Vault health → Fix on entry B while the editor is open and dirty on entry A:
the discard prompt appears, Cancel keeps A open — but openEntryForFix carried
on regardless. It unmasked A's password to cleartext, selected it, and pulsed
the Generate button (the blinking). Wrong entry, and it revealed a password
the user never asked to see.

openSlideOver was void, so callers couldn't tell a cancelled switch from a
successful one. It now returns false when it bails (discard cancelled, entry
not found) and true once the panel actually shows the requested entry;
openEntryForFix checks it before touching the password field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:22:50 +01:00
r-zakarya 660f495649 docs: scenarios for the programmatic-click and round-trip fixes
B14 (native dialogs must not stack a discard prompt) and a new section F
covering the export/import round-trip: favorite + pinned restored, foreign
CSV defaults, sync staying device-local, custom-icon provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:19:23 +01:00
r-zakarya 1638246a53 fix(editor): a programmatic click is not a click-outside
Import with an open, dirty editor showed "Discard unsaved changes?" stacked
on top of the native file dialog. doImport appends its hidden file input to
<body> and calls .click(); that synthetic click bubbles to the document
handler, which sees a target outside .slideover and fires
requestCloseSlideOver.

Same latent bug on the attachment download web-fallback (an <a download>
appended to <body>, then .click()ed).

Gate the handler on e.isTrusted: only a real user click can mean "clicked
away". Fixes both and any future programmatic click, at the shared choke
point. File inputs living inside the slideover (icon upload, attachment
picker) were already safe via closest('.slideover').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:10:19 +01:00
r-zakarya 30e37b3fbd fix(import): restore favorite + pinned on import/backup restore
A pinned entry came back unpinned after export→import. Wider than pinned:
`favorite` was WRITTEN into the export/backup/sync containers but nothing
ever read it back — not parseEntriesFromJSON, not encryptImportEntry, not
the server INSERTs. So it was a write-only field, and a restore silently
dropped both flags. `pinned` wasn't in any container at all.

Wired the full round-trip:
- containers: export + auto-backup now carry `pinned` (favorite was there).
- parseEntriesFromJSON reads both back (absent → 0, e.g. foreign CSV).
- encryptImportEntry passes them through to the body.
- server create + bulk-import INSERTs write favorite/pinned
  (GetValue<Boolean>(..., False) → 0 default). PUT still ignores them, so a
  sync update can't clobber local flags.

Sync stays device-local as decided 2026-07-11: buildSyncSnapshot now OMITS
favorite explicitly (it was dead weight before; with the passthrough live,
leaving it in would have started propagating it). Commented so the intent
isn't re-broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:06:05 +01:00
r-zakarya a6ba0100ee feat(icons): propagate icon_custom through export / import / sync
Follow-up to 18fe13a: the custom-icon flag now survives round-trips instead
of resetting to fetched on a restore.

- Containers write icon_custom: manual export (doExport), auto-backup, sync
  snapshot; parseEntriesFromJSON carries it back (default 0 when absent, e.g.
  foreign CSV).
- encryptImportEntry ships it in the body (shared by bulk-import + sync
  create/update).
- Server create + bulk-import INSERTs read icon_custom
  (GetValue<Boolean>('icon_custom', False) → 0 default). An empty icon always
  forces icon_custom=0.

Now a custom icon stays custom after export→import and across sync — it won't
be silently reclassified as cache and clobbered by Re-fetch-all on the other
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:21:04 +01:00
r-zakarya 18fe13a861 feat(icons): preserve user-uploaded icons from bulk fetch/clear
Auto-fetched favicons and user uploads shared one icon_b64 column, so
"Clear cache" wiped custom uploads and "Re-fetch all" overwrote them — data
loss the user can't undo (a custom icon exists precisely because DDG can't
fetch that site).

New icon_custom flag (0=fetched cache, 1=user upload). Invariant: an
automatic action never touches a custom icon; only the user changes/removes
it per entry.

- Schema: icon_custom INTEGER on vault_entries.
- POST /entries/{id}/icon takes {custom}; GET /entries echoes icon_custom;
  DELETE /entries/icons/all filters COALESCE(icon_custom,0)=0.
- saveEntryIcon(id, uri, isCustom): upload→true, fetch/remove→false.
- backfillFavicons skips icon_custom even on force (Re-fetch all).
- clearAllFavicons clears only non-custom locally + refreshes the open editor
  (the icon preview didn't update on clear before).
- duplicate re-stamps a copied custom icon; import-overwrite carries the flag.

Not propagated across bulk-import / sync (the bulk INSERT doesn't read it) —
a restored icon counts as fetched. Documented; custom icons are typically
self-hosted sites DDG won't re-fetch anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:11:25 +01:00
r-zakarya f45b96d147 docs: test scenarios for the auth / recovery / memory series
Section E (E1-E7) covers the fixes that had none: reauth inline retry,
recovery setup + redeem on argon2id-v2, recovery commit-after-save, the
sessionStorage key removal, and a slideover regression guard after deleting
the dead entry modal. Dropped the now-done "legacy entry modal" item from the
remaining list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:38:16 +01:00
r-zakarya 0434eec85e refactor: remove the dead legacy entry modal
openEntryModal had zero callers — the slideover editor replaced it — but the
whole cluster still shipped: the #entryModal markup, saveEntry (wired to the
form submit), closeEntryModal, populateFolderSelect, updateEntryStrength, and
the entire tag-chip subsystem (editingTags, renderChips, addTag, the suggest
dropdown) plus their init wirings and the Esc-chain branch.

saveEntry was a latent hazard: still bound to #entryForm submit, it did a
PARTIAL PUT (no kind / totp_secret / custom_fields / template) — the exact
silent-data-loss bug moveEntryToFolder documents. Unreachable today, but one
stray call away from wiping fields.

Removed JS + HTML + the orphaned .chip-suggestion CSS. The generator is shared
with the slideover — kept; its dead 'entry' target branch collapsed to
slideover-only. 322 lines gone, tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:35:39 +01:00
r-zakarya d101192e22 fix(recovery): don't commit the code until the user saves it
Generating a recovery code POSTed /recovery-key/setup — which deletes any
existing recovery row and inserts the new one — BEFORE showing the code. So
cancelling the "save this code" modal still erased the old code and activated
a new one the user never saw: a silent path to lockout.

Generate + wrap locally, show the code, and only POST setup after "I saved
it". Cancel now writes nothing — the account is left exactly as it was, old
code still valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:26:32 +01:00
r-zakarya e7dca84f40 fix(recovery): echo Argon2 params in the redeem response
The redeem client already reads `state.argon2Params = r.argon2 || null`, but
the server never sent `argon2`, so it always fell back to null → the default
params. Correct today (every account uses ARGON2_DEFAULT_PARAMS), but a latent
wrong-key derivation the day per-account params diverge: same class as the
setup verifier bug — an Argon2 account derived under the wrong params.

Echo m/t/p from the users row like /login/challenge does; omitted for
non-Argon2 accounts (m=0). No client change — it already consumes the field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:19:16 +01:00
r-zakarya 665d8ea4e6 fix(recovery): accept -v2 / argon2id verifiers in setup
/recovery-key/setup verified the master password with a local copy of the
verifier check that only knew pbkdf2 (legacy) and pbkdf2-sha256. Any account
on a decoupled scheme — pbkdf2-sha256-v2 or argon2id-v2, the current default
after a master-pw rotation — fell through both branches and was rejected as
"invalid password" even when correct, so recovery-key setup was impossible.

Match the canonical VerifierToStoredHash (PM.Handler.Auth): legacy stores the
verifier raw, every other algo is SHA256-wrapped identically. One branch, plus
a keep-in-sync note so the copy doesn't drift again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:00:40 +01:00
r-zakarya 9b7d04ee46 fix(reauth): wrong master pw re-prompts inline instead of closing
Setting a PIN, enabling Quick Unlock, and generating a recovery key all
closed the reauth modal and showed a toast on the first wrong master
password — no retry. Export already looped inline; the others didn't.

Extracted that loop into reauthLoop(message): re-prompts with an inline
error + attempt count (5 tries), honours the 429 lockout, returns the
verified password or null. All four sensitive-action flows now share it, so
a mistyped master password keeps the modal up to try again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:52:05 +01:00
r-zakarya b542106c76 security: stop caching the raw vault key in sessionStorage
A memory dump of the WebView2 renderer (vault unlocked) showed the AES vault
key sitting in sessionStorage as a clean base64 string — a plaintext copy of
the master secret, trivially grep-able, present the whole session.

persistCryptoKey/restoreCryptoKey existed only to survive a page reload, but
the shell has no reload path (F5/Ctrl+R blocked, every navigation is a cmd://
bridge call), so the branch that consumed it was unreachable. Removed both
functions, their 7 call sites, and collapsed the init restore branch into the
plain cold-start path (Quick Unlock → PIN → master-pw, all already there).

Also scrub the derived key bytes from the heap (keyBytes.fill(0)) once the
CryptoKey and verifier are built — the CryptoKey keeps its own copy.

Not fixable here, documented: the key stays extractable:true (Quick Unlock,
PIN, recovery and master-pw change all exportKey it), and decrypted password
strings still linger in the V8/Blink heap after lock — immutable strings the
GC never zeroes, inherent to a JS/WebView2 password manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:40:56 +01:00
r-zakarya de50a5f71f docs: A11 fixed, plus its false-positive and deleted-entry scenarios
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:45:10 +01:00
r-zakarya 5f807bcf23 fix(editor): warn before overwriting an entry changed elsewhere (A11)
The editor re-ships the payload it loaded on open, so a sync or import that
rewrote the same entry meanwhile was silently clobbered by Save — the last
hole editorAbsorbChange doesn't cover, since those paths rewrite every field
including the ciphertext.

Save now compares the live entry against the baseline soState already keeps
and asks before overwriting. Ciphertext comparison, so no decryption; changes
the editor absorbed read as equal (editorAbsorbChange updates `original`
too), so local tag/folder actions never trip it; the post-save reload
re-baselines, so a second save is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 22:44:31 +01:00
r-zakarya 4ceb7ab872 docs: scenarios for the Esc-chain and icon-refresh fixes
A10b (editor icon preview), B11 (Esc closes the top modal, not the editor,
across all 11 modals), B12 (a static modal survives its Esc), B13 (toast /
busy-overlay clicks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:19:49 +01:00
r-zakarya 04bc809c71 fix(editor): refresh the icon preview on outside icon writes
A10: the editor's icon preview is painted by a closure nothing outside can
reach, so a favicon fetched from Settings (or an import overwrite) updated
the cards and left the open editor showing the old initials. soIconField
publishes its painter; saveEntryIcon — the choke point for every icon write
— routes through editorAbsorbChange like tag/folder already do.

Also whitelists .toast and #busyOverlay in the editor's click-outside: both
can sit under the cursor during a long operation, and clicking one counted
as clicking away.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:19:21 +01:00
r-zakarya 47c29bd04e fix(esc): reauth / picker / quick-search were missing from the Esc chain
Three static modals had no entry in the fallback Esc handler, so Esc over
them fell through and closed the editor behind (or did nothing when no
editor was open — reauth and the autofill picker could only be dismissed by
mouse).

Also scopes the stray-modal catch added in a95becd to [data-dynamic]. As
written it matched ANY open .modal, so an Esc over reauth / quick-search /
the autofill picker would have removed those static nodes from the DOM for
good, breaking the feature until restart. Only the runtime-built folder
modal carries the marker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:17:53 +01:00
r-zakarya a95becd582 fix(editor): Esc over the folder modal no longer closes the editor
The folder modal is built dynamically with no id in the fallback Esc
handler's modal list, so Esc fell straight through to the slideover close
and shut the editor behind it. The capture handler already bails on any
open .modal; mirror that in the fallback by closing a stray .modal before
reaching the slideover branch. Covers every dynamically-built modal, not
just this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:17:45 +01:00
r-zakarya 6f4363bde6 fix(batch): move the selection bar into the title row
Selecting an entry inserted the batch bar as a block above the grid, pushing
every row down ~60px — the list jumped under the cursor mid-click. The bar
now renders inside .content-header, to the right of "All items", and the
header reserves its height (min-height) so appearing and disappearing shifts
nothing at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:59:53 +01:00
r-zakarya 9ea26be422 docs: manual test scenarios for the concurrent-write / editor bugs
Covers the four classes found this session (stale editor payload, editor
dismissed without the discard gate, missing withEncryptedMeta, view-blind
UI) with the surrounding cases, not just the ones reported.

Also fixes one more instance found while auditing: deleting a folder
reassigns its entries to All server-side, but an editor open on one of them
kept pointing at the deleted folder and re-created it on Save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:51:12 +01:00
r-zakarya d146fbe796 fix(editor): outside tag/folder changes land in the open editor
Tagging or moving an entry from the batch bar / kebab / drag while that same
entry sat open in the editor was lost on the next Save: the editor still held
the payload it loaded before the change and re-shipped it wholesale.

editorAbsorbChange(id, {tag|folder}) mirrors the change into soState AND
soState.original (so the dirty check doesn't fire on an edit the user didn't
type), called from all five write paths: batchAddTag, batchMoveToFolder,
addTagToEntry, moveEntryToFolder, moveEntriesToFolder.

Two bugs found next door:
- moveEntriesToFolder (multi-drag) PUT without withEncryptedMeta. The
  cleartext columns are gone server-side, so it shipped fields the server
  can't store and blanked title/site/username/tags at rest.
- The kebab menu offered Pin/Edit/Duplicate/Move to trash on TRASHED rows —
  the table view reaches it in trash, where only Restore / Delete forever
  make sense.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:44:10 +01:00
r-zakarya e1ddc65f85 fix(editor): batch-bar clicks no longer dismiss the editor
The click-outside handler whitelists chrome (topbar, sidebar, pagination…)
but not .batch-bar, so Delete / Clear / Move to folder / Add tag counted as
an outside click and ran requestCloseSlideOver before their own handler.
Whitelist it, plus .content-header which carries buttons of its own
(Empty trash). Those actions now decide for themselves what happens to the
editor via closeEditorForDeleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:33:52 +01:00
r-zakarya 3dbce780d2 fix(delete): one editor gate for every delete path
Batch Delete had the bug the single ✕ just lost: it never touched the open
editor, so an entry could be trashed while its editor stayed open on it,
unsaved edits and all. Same hole in batch purge, permanent delete and empty
trash.

closeEditorForDeleted(ids, alreadyConfirmed) now owns the rule for all five
call sites: close the editor only when it holds one of the deleted ids, and
show the discard prompt only when no delete modal was shown (one modal per
action; cancelling it deletes nothing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:22:14 +01:00
r-zakarya 4b9ffaab62 fix(trash): warn about unsaved edits when delete asks nothing
With askBeforeDelete off, clicking ✕ on the entry being edited trashed it
and closed the editor with no prompt whatsoever — a misclick cost the edits
silently. That path now runs the discard gate (no-op unless dirty). When
askBeforeDelete is on, its own confirm still stands alone: one modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:09:15 +01:00
r-zakarya 4c2287fd35 fix(trash): don't close the editor when another entry is trashed
Clicking the ✕ on any row called closeSlideOver() unconditionally, so an
open editor with unsaved edits on a DIFFERENT entry vanished without the
discard prompt. Close it only when it holds the entry being trashed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 23:03:45 +01:00
r-zakarya 3f07c1217b feat(notes): eye toggle to mask the note body in the editor
The note textarea showed its plaintext to anyone glancing at the screen,
unlike every other secret in the editor. An eye button on the NOTE label
masks it with -webkit-text-security (a textarea has no type="password").

The button IS the setting: it flips state.maskNotes, which is synced via
settings_json, so the choice sticks across entries and devices — no extra
Settings row for a one-bit preference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 20:16:06 +01:00
r-zakarya 72ef034459 fix(list): stop the folder/tag chips drifting row to row
.entry-meta and the first order-6 action both had margin-left:auto, so the
free space was SPLIT between them — the chips landed at a different x on
every row depending on how wide the rest of the row was. .entry-meta is
always present (empty when the entry has no chips), so its auto alone puts
the actions at the far edge and right-aligns the chips against them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:56:48 +01:00
r-zakarya a0b6c5c67a style(list): note rows get the same 110px track as password rows
The note placeholder was still flex:1, so note rows' copy button sat at the
far edge while password rows' sat at 110px. Same fixed track for both, full
label on hover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:53:25 +01:00
r-zakarya 506230f138 fix(list): shrink the password pill and tooltip truncated names
The list-view rule set `width: 110px`, but the base .entry-pw is `flex: 1`
(basis 0), so width was ignored and the pill stretched the full row width —
dragging the copy/open buttons to the far edge. `flex: 0 0 110px` pins it,
and the actions land right after it.

Names that ellipsise in the fixed-width title column (list) and the name
cell (table) now carry a title attr, so the full name shows on hover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:50:52 +01:00
r-zakarya bef739b7ca fix(layout): stop rows showing through above the sticky title
A sticky box is held so its *margin* box respects `top`, so the -24px
margin-top used to cancel .content's padding pushed the header's border box
24px down from the scrollport edge — rows scrolled through the strip above
it. Move the top padding off .content and onto .content-header instead: same
spacing at rest, opaque box flush with the scrollport when stuck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:43:57 +01:00
r-zakarya 24d74df2ab feat(layout): stick the title row too, and chain the sticky offsets
"All items · N items" scrolled away like the pager did. It now sticks at
the scrollport top (padding-top/negative margin cancel .content's padding
so nothing shows through the gap), and the three things below it offset
by --hdr-h: batch bar, pagination, and the table header (--hdr-h + --pag-h).
Both vars are measured on .content in renderEntries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:31:52 +01:00
r-zakarya 60cf26b259 feat(pagination): keep the pager visible while scrolling
The page buttons sat at the top of the grid and scrolled away, so paging
through a long list meant scrolling back up. Sticky at the scrollport top
with an opaque background; the table header now sticks below it via
--pag-h (measured after append, so a wrapped pager still lines up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 11:22:50 +01:00
r-zakarya a1e028bfc5 fix(table): right-align the column-picker header cell
The gear hugged the left edge of the (width:100%) actions column because the
header th, unlike the body cells, has no col-actions class / text-align:right.
Set textAlign right on the actions header cell so the picker sits in the
top-right corner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 06:51:52 +01:00
r-zakarya 4dfd908a9a style(table): move the column picker into the table header corner
The show/hide-columns gear only makes sense in table view but sat in the
global toolbar (needing applyColsWrapVisibility to hide it elsewhere). Build
it inside the actions-column header cell in renderTable instead, with inline
handlers (recreated each render, so no dependence on once-wired listeners on
a static node). Removed #colsWrap from the toolbar + the now-obsolete
applyColsWrapVisibility and its callers; kept the menu id so
renderTableColsMenu still populates it, and a live-queried outside-click close.
Added .cols-wrap CSS (was an inline position:relative on the old node).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:00:56 +01:00
r-zakarya 0d40328a24 feat(hotkey): make the new-entry hotkey configurable + fix tray title capture
Part A — Ctrl+Shift+A (new entry from window title) is now editable like the
other 3 global hotkeys, following the same pattern end to end: state.newEntryHotkey
(synced), a capture button in Settings > Autofill, dup cross-check, reset-to-
defaults, autofillPushHotkeys, and Bridge.setAutofillHotkeys carrying ne_mods/
ne_vk. Delphi gets SetNewEntryHotkey + stores the live combo (FNewEntryHotkeyMods/
Vk) so ExecuteAutofill's post-fill re-register uses the user's combo instead of
resetting to Ctrl+Shift+A. UMainForm parses ne_* unconditionally (works with
autofill off).

Part B — new-entry from the tray pre-filled the title with "Password Manager":
GetForegroundWindow resolved to our own hidden window. Only capture the title
when the foreground window belongs to another process; otherwise pass '' (blank).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:49:48 +01:00
r-zakarya a8e0a3da00 fix(autofill): unregister new-entry hotkey during SendInput (chord fire)
Ctrl+Shift+P (password autofill) with Ctrl+Shift physically held triggered
new-entry: the clear-field step injects a real Ctrl+A, and the held modifiers
(re-pressed by keyboard auto-repeat faster than ForceReleaseModifiers clears
them) make the system see Ctrl+Shift+A = our new-entry global hotkey. Injected
keys are only Ctrl+A + Tab, and only 'A' matches a hotkey, so unregister
NEW_ENTRY around the SendInput (try/finally re-register) — no WM_HOTKEY is
generated. Password/username go as VK_PACKET unicode, can't chord.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:24:09 +01:00
r-zakarya 5799407f55 fix(settings): sync the view-mode toolbar button to the server value on load
viewMode is a synced setting. On launch localStorage is wiped (ephemeral port
= new origin) so state.viewMode defaults to 'cards' and applyViewMode()
highlights the cards button. loadServerSettings then set state.viewMode='table'
and re-rendered the content (table) but never re-synced the .view-btn
highlight or the column-picker — so table content showed under a cards-active
toolbar. Re-apply both in the visual-settings block, alongside theme/sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:24:09 +01:00
r-zakarya 370432f638 fix(kdf): neutralize the /migrate-kdf endpoint server-side (6c-1 / 6c-4)
6c-1 removed the client caller, but the server handler stayed armed: a stray
authed+CSRF'd call could still re-encrypt only 1 blob of 8 (data loss) or
rewrite a -v2 account's hash_algo (permanent lockout, 6c-4). Gutted the ~165
destructive lines and replaced the body with an auth+CSRF gate then HTTP 410.
Both risks are now closed server-side, not merely unreachable by convention.
The vestigial kdfMigration login signal is left (client ignores it, endpoint
410s) — removing it would cascade through a login-path param for no benefit.
Fixed the now-false comments pointing at migrate-kdf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:43:39 +01:00
r-zakarya 47aec30934 docs(audit): sync 6c status — 6c-1/2/3/9 done, 6c-4 moot, order table + verdict
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:36:36 +01:00
r-zakarya 2e7aebf651 fix(db): enable SQLite foreign_keys explicitly (audit 6c-9)
SQLite defaults foreign_keys OFF per connection, and the schema relies on
ON DELETE CASCADE (entry_attachments, entries_password_history, recovery_keys).
FireDAC is said to default it On but that's undocumented here — set
ForeignKeys=On so a permanent delete never orphans encrypted blobs. Correct
in both cases (no-op if already On). Base check before fix: 0 orphans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:33:46 +01:00
r-zakarya 19b66b7bbd fix(csrf): validate against the presented session, not the most recent (6c-3)
RequireCSRF looked up csrf_token by user_id ORDER BY created_at DESC LIMIT 1
— the newest session, not the one whose Bearer token was presented. Two
consequences: created_at's 1-second resolution made two same-second sessions
a coin-flip (phantom 403s), and conceptually it accepted "any valid CSRF for
this user" rather than the one bound to this session. Now queried by
token_hash = SHA256(bearer) AND user_id. Authenticate already located this
row + checked expiry, so the expiry clause is dropped. Signature unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:30:16 +01:00
r-zakarya c2cc9c72e6 fix(session): write expires_at in UTC, not local time (audit 6c-2)
CreateSession wrote FormatDateTime(IncHour(Now,24)) = local, but RequireCSRF
and CleanupExpired compare against SQLite datetime('now') = UTC. On negative
UTC offsets the CSRF window fell short of the 24h auth window: between the two,
GETs authed fine but every write got 403 "Invalid CSRF token". Now
IncHour(NowUTC,24) on write + LExpires < NowUTC in Authenticate, so all three
readers are UTC-consistent. Last FormatDateTime(...,Now) in the backend —
closes §2.2 for real. NowUTC + DateUtils already in uses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:25:37 +01:00
r-zakarya 71f45eb8e1 fix(kdf): disable the data-loss KDF auto-migration path (audit 6c-1)
/migrate-kdf changed the AES key (100k->600k) but only re-encrypted
encrypted_password, leaving totp_secret/custom_fields/*_enc + attachments
under the old key = permanent data loss. Both live accounts are already at
600k so it never fired, but the path was armed and auto-triggered by the
server's login signal.

Removed the two triggers (doLogin + unlock) and the now-dead runKdfMigration.
Legacy <600k accounts upgrade safely via Change Master Password instead
(re-encrypts everything + moves to Argon2id). The Delphi endpoint is left
inert: without the client orchestrating the key swap it can't corrupt
anything, so no untested server change. Closes 6c-4 too (unreachable now).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 05:20:07 +01:00
87 changed files with 21176 additions and 1211 deletions
+7
View File
@@ -31,3 +31,10 @@
*.ico binary *.ico binary
*.png binary *.png binary
*.rar binary *.rar binary
# RAD Studio exige CRLF dans les sources Pascal — sans cette regle, git les
# normalise en LF au commit et le compilateur le signale a chaque unite.
*.pas text eol=crlf
*.dpr text eol=crlf
*.dproj text eol=crlf
*.fmx text eol=crlf
+37
View File
@@ -62,7 +62,44 @@ delphi-backend/vault - Copie*.db
*.png *.png
*.jpg *.jpg
*.jpeg *.jpeg
# Les icones du lanceur sont du contenu, pas des captures d'ecran de passage.
# Elles se regenerent par make-icons.ps1, mais les suivre evite d'avoir a le
# savoir - et de dependre de System.Drawing pour fabriquer un APK.
!android-client/android-res/**/*.png
# Delphi build artifacts # Delphi build artifacts
delphi-backend/*.vrc delphi-backend/*.vrc
delphi-backend/*.$manifest delphi-backend/*.$manifest
# PHP sync endpoint — holds the bearer token / the snapshot itself
server-php/config.php
vault-data/
# Sorties de compilation Android (regenerees a chaque build)
Android/
Android64/
*.o
*.apk
*.apk.idsig
pmserverApk.res
# Mot de passe de sync du banc d'essai - jamais versionne
android-client/PMA.LocalConfig.inc
# Genere depuis theme.rc + pm-dark.style, tous deux suivis (voir README).
android-client/theme/theme.RES
android-client/theme/theme.res
android-client/web/*.res
# Magasins de cles de signature. Le perdre interdit toute mise a jour de
# l'application deja installee, Android refusant une signature differente :
# il se sauvegarde ailleurs, il ne se versionne pas.
*.jks
*.keystore
# Cle privee des licences. Qui la detient fabrique des licences valables :
# elle vit dans un gestionnaire de mots de passe, pas dans un depot.
tools/licence-private.key
# Blocs brouilles intermediaires (BuildAssets.ps1). Regeneres a chaque build,
# comme assets.res : jetables, pas versionnes.
delphi-backend/assets/obf/
+116
View File
@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- BEGIN_INCLUDE(manifest) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="%package%"
android:versionCode="%versionCode%"
android:versionName="%versionName%"
android:installLocation="%installLocation%">
<uses-sdk android:minSdkVersion="%minSdkVersion%" android:targetSdkVersion="%targetSdkVersion%" />
<!-- LA LISTE EST ECRITE ICI, PAS HERITEE. Le marqueur de substitution qui
se trouvait a cette place y deversait les defauts de Delphi :
localisation fine et grossiere,
camera, telephone, etat du telephone, stockage externe en lecture et
en ecriture. Aucune n'est utilisee, et sur un coffre a mots de passe
ce sont exactement les mauvais signaux - celui qui lit la fiche de
l'application y voit une demande de position et d'appareil photo sans
explication possible.
Il n'en reste qu'une : le reseau, pour parler au depot. Les deux de
biometrie ci-dessous sont deja explicites depuis longtemps. Ajouter
une permission demande donc de l'ecrire ici, ce qui est le but.
NE PAS ECRIRE LE NOM DU MARQUEUR DANS CE COMMENTAIRE : la substitution
est textuelle et ne regarde pas si elle tombe dans un commentaire -
le nommer pour l'expliquer suffit a le faire revenir. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Absente de la liste par defaut de Delphi, et sans elle BiometricPrompt
leve java.lang.SecurityException a la premiere invite - pas au
demarrage, donc le defaut ne se voit qu'en essayant d'ouvrir le coffre.
USE_FINGERPRINT couvre les versions anterieures a Android 9, ou
BiometricPrompt n'existe pas encore. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-feature android:glEsVersion="0x00020000" android:required="true"/>
<queries>
<%queries-child-elements%>
</queries>
<!-- networkSecurityConfig REMPLACE usesCleartextTraffic="true", qui
autorisait le clair vers n'importe quel hote pour la seule raison que
l'essai local via "adb reverse" parle HTTP. La configuration ne
l'autorise plus que vers la boucle locale, nommement : le jeton ne
peut plus sortir qu'en TLS, et une URL http:// vers un vrai hote
echoue bruyamment au lieu de le laisser passer en clair.
Voir android-client/android-res/xml/network_security_config.xml. -->
<!-- allowBackup=false : le repertoire prive contient vault.bin, c'est-a-dire
le coffre entier. Il est chiffre par une cle qui ne quitte pas la puce,
donc ce qui sortirait serait inexploitable - mais il sortirait, et une
sauvegarde cloud n'a rien a faire dans le chemin d'un gestionnaire de
mots de passe. fullBackupContent et dataExtractionRules ne sont pas
necessaires une fois la sauvegarde refusee en bloc. -->
<application
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:persistent="%persistent%"
android:restoreAnyVersion="%restoreAnyVersion%"
android:label="%label%"
android:debuggable="%debuggable%"
android:largeHeap="%largeHeap%"
android:icon="%icon%"
android:theme="%theme%"
android:hardwareAccelerated="%hardwareAccelerated%"
android:resizeableActivity="true"
android:requestLegacyExternalStorage="true">
<%provider%>
<%application-meta-data%>
<%uses-libraries%>
<!-- Trigger Google Play services to install the backported photo picker module. -->
<service
android:name="com.google.android.gms.metadata.ModuleDependencies"
android:enabled="false"
android:exported="false"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
</intent-filter>
<meta-data android:name="photopicker_activity:0:required" android:value="" />
</service>
<%services%>
<!-- Our activity is a subclass of the built-in NativeActivity framework class.
This will take care of integrating with our NDK code. -->
<activity
android:name="com.embarcadero.firemonkey.FMXNativeActivity"
android:exported="true"
android:label="%activityLabel%"
android:configChanges="orientation|keyboard|keyboardHidden|screenSize|screenLayout|uiMode"
android:launchMode="singleTask">
<!-- Tell NativeActivity the name of our .so -->
<meta-data android:name="android.app.lib_name" android:value="%libNameValue%" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FMX n'affiche pas BiometricPrompt depuis notre NativeActivity : il
lance SA PROPRE activite, qui heberge le fragment androidx. Elle
vit dans fmx.dex.jar, mais Android exige qu'elle soit declaree ici
- sinon android.content.ActivityNotFoundException a la premiere
invite, et nulle part avant.
Theme translucide du framework : cette activite ne dessine rien
d'elle-meme, elle ne fait qu'heberger le dialogue systeme. Sans
cela elle recouvrirait l'ecran d'une surface opaque le temps de
l'authentification. On prend un theme @android: et non AppCompat,
absent des ressources generees. -->
<activity
android:name="com.embarcadero.firemonkey.biometricauth.BiometricFragmentActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<%activity%>
<%receivers%>
</application>
</manifest>
<!-- END_INCLUDE(manifest) -->
+409 -9
View File
@@ -22,6 +22,44 @@ Toute modif `index.html` / `js/` / `css/` nécessite :
Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le
plus courant après modif frontend. plus courant après modif frontend.
### Blocs embarqués brouillés
Depuis 2026-09-05, `BuildAssets.ps1` **brouille** chaque fichier avant de le
donner à `brcc32` (dans `assets/obf/`, gitignoré ; les sources en clair ne sont
jamais touchées) et `PM.EmbeddedAssets.DeobfuscateAsset` le débrouille au moment
de servir.
```
bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
sortie = XOR( magie 'PMA1' + contenu du fichier )
```
- **C'est de l'opacité, pas du secret.** La clé est dans l'exe : qui tient le
binaire retrouve le texte. Ce que ça supprime, c'est l'onglet *Image* de
Process Explorer et n'importe quel `strings` — le frontend n'est plus lisible
en survolant le fichier. L'onglet *Memory*, un débogueur ou la réponse HTTP le
rendent toujours : WebView2 doit recevoir du JavaScript en clair pour
l'exécuter. D'où **XOR plutôt qu'AES** : à sécurité rigoureusement égale
(nulle, contre qui a l'exe), dix lignes au lieu d'un appel CNG, et surtout
aucune force affichée qu'on n'a pas.
- Le flux dépend du **nom de la ressource** : tous les fichiers commencent par
les mêmes lignes de commentaire, un flux commun rendrait ces préfixes
identiques d'un bloc à l'autre.
- **La magie `PMA1` préfixe le clair.** Sans elle, une clé qui a dérivé entre le
script et l'exe servirait du binaire au navigateur : écran blanc, aucune
trace. Avec elle, `TryServeEmbedded` refuse. **Symptôme à reconnaître :
`/index.html` en 404 juste après avoir touché `AssetObfSecret` d'un seul
côté** — les deux constantes (`$ObfSecret` / `AssetObfSecret`) doivent rester
identiques.
- **Coût mesuré : 2,45 ms** pour les 820 Ko embarqués (AES chronométré comme
borne haute ; le XOR est plus rapide), une seule fois au chargement de la
page. À comparer aux ~650 ms d'Argon2id au déverrouillage.
- **Les tests frontend ne sont pas concernés** : ils lisent les `.js` sur le
disque, jamais le `.res`.
- Vérifié en comparant les flux des deux côtés bloc par bloc (ils doivent être
identiques au bit près) puis en refaisant l'aller-retour sur `app.js`,
`index.html` et `style.css`.
### Découpage frontend (§3.1, en cours) ### Découpage frontend (§3.1, en cours)
`app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script `app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script
@@ -148,17 +186,296 @@ Retour Delphi → JS via `WebBrowser.ExecuteJavaScript('Bridge.onX(...)')`.
- `Ctrl+Shift+L` : autofill complet (user + Tab + password) - `Ctrl+Shift+L` : autofill complet (user + Tab + password)
- `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens) - `Ctrl+Shift+P` : autofill password seul (forms step-2, unlock screens)
- `Ctrl+Shift+T` : autofill du **code 2FA** (cliquer le champ 2FA d'abord).
ID hotkey 47 / `akTotp`. Ne matche que les entrées avec `totp_secret` (filtre
appliqué **avant** le scoring → le picker ne propose jamais un candidat sans
TOTP). JS génère le code et l'envoie **dans le slot password avec un username
vide** → réutilise le chemin password-only de `ExecuteAutofill`, aucun code
natif en plus.
- `Ctrl+Shift+A` : new entry pré-rempli avec le titre de la fenêtre foreground - `Ctrl+Shift+A` : new entry pré-rempli avec le titre de la fenêtre foreground
- `Ctrl+Shift+D` : toggle debug panel (uniquement si `config.txt` présent) - `Ctrl+Shift+D` : toggle debug panel (uniquement si `config.txt` présent)
Combos autofill configurables depuis Settings (synced en DB Combos autofill configurables depuis Settings (synced en DB
via `settings_json`). via `settings_json`).
## Fill bar (remplir à la souris)
Menu du tray → **Fill bar** : une barrette de trois
boutons *Full* / *Password* / *2FA* qui flotte au-dessus de tout et fait
exactement ce que font `Ctrl+Shift+L` / `Ctrl+Shift+P` / `Ctrl+Shift+T`. Deux
boutons de plus, *Open* et `X` : quand la barrette est allumée, elle est souvent
la seule chose de l'application à l'écran — revenir au coffre ou s'en
débarrasser ne doit pas obliger à retrouver l'icône du tray.
**`WS_EX_NOACTIVATE` est toute l'astuce.** Une fenêtre ordinaire prendrait le
premier plan au clic — le champ que l'utilisateur vient de cliquer perdrait son
curseur, et `GetForegroundWindow` nous désignerait nous-mêmes comme cible.
NOACTIVATE veut dire que le clic ne déplace jamais le premier plan : le
navigateur reste actif, son champ reste focalisé, et la capture voit exactement
ce que voit le chemin hotkey.
Détails :
- Fenêtre créée par un **second** `AllocateHWnd` (`FLazyWnd`), séparée de la
fenêtre message-only : `WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`,
deux enfants `BUTTON` (IDs 101/102) → `WM_COMMAND` → `FireAutofillRequest`.
- `FireAutofillRequest(AKind)` est le corps **extrait** du handler `WM_HOTKEY` :
hotkey et barrette partagent capture de la cible, matching de titre et picker.
Un seul chemin, deux gestes.
- `WM_NCHITTEST` → `HTCAPTION` : toute la barrette est sa propre barre de titre
(elle finit par se poser sur le champ qu'elle remplit, il faut pouvoir la
pousser). `WM_ERASEBKGND` peint le fond — la classe `TPUtilWindow`
d'`AllocateHWnd` n'a **pas** de brosse de fond.
- **Le nom interne est resté `lazy`** — `FLazyWnd`, `LAZY_*`, les prefs
`lazyOn`/`lazyPos`/`lazyAlphaPct`. Seul le libellé a changé : renommer les
clés de prefs perdrait les réglages déjà posés sur la machine.
- **L'état allumé/éteint est retenu** en pref DPAPI (`lazyOn`) et repris au
démarrage par `RestoreLazyBar`, appelé depuis `FormCreate` **après**
`ApplyTitleBarTheme` et après le câblage de `OnTrayRestore` : la barrette se
peint elle-même (il lui faut le thème) et son bouton *Open* passe par cet
événement.
- Position : coin bas-droit de la *work area* au premier lancement, puis
**retenue** en pref DPAPI (`lazyPos`, `'x,y'`), écrite sur `WM_EXITSIZEMOVE`
(une écriture par déplacement, pas une par pixel parcouru). Elle n'est reprise
que si `MonitorFromRect(…, MONITOR_DEFAULTTONULL)` la trouve encore sur un
écran : la barrette n'a **pas d'entrée dans la barre des tâches**, donc une
position héritée d'un écran débranché la rendrait définitivement
irrécupérable.
- Le `X` **poste** `WM_LAZY_CLOSE` à la fenêtre message-only au lieu de détruire
sur place : libérer une fenêtre depuis son propre gestionnaire libérerait, en
plein envoi, l'instance qui lui sert de window proc.
- **Transparence au repos** (`WS_EX_LAYERED` + `SetLayeredWindowAttributes`) :
pleine opacité quand la souris est dessus, `FLazyAlphaPct` sinon. Réglée par
**clic droit sur la barrette** → 100/75/50/25 %, persistée en pref DPAPI
(`lazyAlphaPct`, défaut 60) — **pas** dans `settings_json`, qui exige une
session alors que la barrette peut être allumée coffre fermé. Le menu
appartient à la fenêtre message-only : `TrackPopupMenu` réclame un
`SetForegroundWindow` pour se refermer seul, et le faire sur la barrette
détruirait le `NOACTIVATE` qui la fait fonctionner. Deux portes vers ce menu —
`WM_NCRBUTTONUP` (le fond est non-client à cause de `HTCAPTION`) et
`WM_CONTEXTMENU` (remonté par les boutons).
- **`TOPMOST` n'est pas un rang, c'est une bande** : la barre des tâches y est
aussi, et cliquer son fond vide la remonte en tête de cette bande — une
barrette posée dessus passe **derrière** et semble avoir disparu. Le même
battement de 200 ms la repose (`SetWindowPos … SWP_NOMOVE or SWP_NOSIZE`) ;
sans mouvement ni taille à changer, l'appel ne repeint rien. **Ce battement
s'arrête pendant le menu d'opacité** (`KillTimer` / `SetTimer` autour de
`TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la
bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de
l'ouvrir.
- **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`,
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
tranche entre clic et glissement en respectant le seuil réglé par
l'utilisateur. Glissement → `ReleaseCapture` + `WM_SYSCOMMAND, SC_MOVE or
HTCAPTION`. Clic → on **envoie `BN_CLICKED` à la place du bouton** : DragDetect
a avalé le `WM_LBUTTONUP`, lui rendre la main le laisserait enfoncé pour
toujours. Le clic droit n'est pas intercepté, donc le menu d'opacité tient.
- Le survol est **sondé** par un `SetTimer` de 200 ms, pas par `TrackMouseEvent` :
avec quatre fenêtres enfants, passer de la barrette à un bouton envoie déjà un
`WM_MOUSELEAVE` au parent. Comparer le curseur au rectangle ignore ce piège.
- Transparente **n'est pas traversante** : la barrette avale toujours les clics
sur toute sa surface (`WS_EX_TRANSPARENT` contredirait ses boutons).
- **Thème** : boutons `BS_OWNERDRAW` + `WM_DRAWITEM`, palette recopiée des
variables de `css/style.css` en COLORREF (`$00BBGGRR` — octets **inversés**
par rapport à l'hexa CSS). `SetPreferredAppMode(ForceDark)` ne themise que ce
que le système dessine lui-même (menus, ascenseurs, infobulles) : un bouton
Win32 standard reste gris clair sur fond sombre. `ApplyTitleBarTheme` mémorise
le mode dans `FDarkTheme` et `RedrawWindow(… RDW_ALLCHILDREN)` la barrette.
Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se
sert dans les couleurs système.
## Licences (`PM.Licence`)
Verification **hors ligne** d'une licence signee ECDSA P-256. Une licence est
une chaine `charge-utile.signature`, chacune en base64url, fabriquee par
`tools/licence.ps1` avec la cle privee qui reste chez l'editeur. Seule la cle
**publique** est dans l'exe : elle ne permet que de verifier.
```
tools\licence.ps1 -Init # une seule fois : cree la paire,
# imprime la constante Delphi
tools\licence.ps1 -For "a@b.com" -Name "Ami" # une licence
```
- **`LicencePublicKeyBlob`** (72 octets) est un `BCRYPT_ECCPUBLIC_BLOB` :
magic `ECS1` + longueur(32) + X(32) + Y(32). **Livre a zero** — tant qu'on
n'y a pas colle la sortie de `-Init`, aucune licence n'est valide. Ne jamais
regenerer la paire ensuite : toutes les licences deja emises tomberaient.
- **La cle privee s'ecrit dans `%USERPROFILE%\Documents\pmserver-licence-private.key`,
hors du depot par construction.** Elle a d'abord vecu dans `tools\`, ou elle
n'attendait qu'un menage pour disparaitre — ce qui est arrive le 2026-09-05,
sans copie, obligeant a regenerer la paire et a reemettre. Le `.gitignore` la
protegeait du depot ; rien ne la protegeait de son proprietaire. Sa vraie
place est un coffre ; le fichier n'est qu'un intermediaire, et `-KeyFile`
permet de la relire d'ou on l'a mise.
- **Perdre la privee et perdre la publique n'ont pas le meme effet** : sans la
privee on ne peut plus emettre AUCUNE licence nouvelle (les existantes
continuent — l'exe n'a que la publique) ; la reparation est de regenerer,
reconstruire et reemettre chacune. Indolore avec une licence en circulation,
ingerable avec vingt.
- **Aucun ASN.1 nulle part.** .NET sur Windows EST CNG : la signature produite
par le script (`r||s` brut, 64 octets) est deja celle qu'attend
`BCryptVerifySignature`, et les coordonnees exportees se rangent telles
quelles dans le blob. Le decodage DER, moitie penible de l'exercice
d'habitude, n'existe pas ici.
- `BCryptVerifySignature` attend **l'empreinte**, pas le message : SHA-256 est
calcule avant l'appel (`THashSHA2.Create` + `Update`, pas `GetHashBytes` qui
n'a pas de surcharge `TBytes`).
- **La signature d'abord, la lecture ensuite** : `SplitAndVerify` n'analyse
jamais une charge utile dont il ne sait pas encore qu'elle vient de nous.
- `IsLicensed` **REVERIFIE** la licence rangee au lieu de lire un drapeau : un
drapeau se retourne, une signature se refabrique seulement avec la cle privee.
Resultat mis en cache en memoire, invalide par `ActivateLicence`/`ClearLicence`.
- Rangee en pref DPAPI (`licenceKey`) — liee au compte Windows, survit au
changement de port comme le reste.
- **Pas d'expiration dans la charge utile**, volontairement : une date qu'on
fait respecter avec l'horloge de la machine du client ne prouve rien. Le
champ `d` n'est qu'une date d'emission.
### Liage au poste (`PM.MachineId`)
Le champ `m` de la charge utile porte l'empreinte du poste ; `MachineMatches`
la compare **apres** la verification de signature — une empreinte n'a de sens
que dans une charge utile dont on sait deja qu'elle vient de nous.
- Empreinte = `SHA256('pmserver/machine/v1|' + UUID carte mere + '|' + serie du
volume systeme)`, tronquee a 16 hexa, affichee en groupes de quatre
(`CD6F-F1B3-25C4-0111`). **Hachee, pas concatenee** : elle se recopie dans un
courriel, et l'UUID brut de la carte mere est un identifiant materiel unique
qui n'a pas a circuler en clair ni a finir dans nos journaux.
- UUID lu par WMI (`Win32_ComputerSystemProduct`, COM via `Winapi.ActiveX` pour
`IEnumVariant`). **`BoardUUID` appelle `CoInitializeEx` lui-meme** : sans ca
l'empreinte dependait de qui avait demarre avant — apres l'init de WebView2
elle lisait le vrai UUID, avant elle echouait et retombait sur `MachineGuid`,
soit DEUX empreintes pour une seule machine et une licence liee a l'une que
l'autre refuse. Un identifiant qui depend de l'ordre de demarrage n'est pas un
identifiant. **Et on ne fait PAS de `CoUninitialize` symetrique** : il parait
propre et se paie d'une violation d'acces, les mandataires WMI et le cache de
type-info de ComObj se liberant plus tard, dans un appartement ferme. **Un UUID nul ou tout a FF est rejete** : beaucoup de VM et
d'images clonees l'annoncent, et s'en contenter ferait tomber des postes
differents sur la MEME empreinte — une licence emise pour l'un ouvrirait chez
l'autre. Repli sur `MachineGuid` (HKLM\...\Cryptography), distinct par
installation. Les deux sources absentes → `'unavailable'`, qui ne correspond a
aucune licence : mieux qu'une chaine vide, identique partout.
- Calculee une fois puis retenue (l'appel WMI coute quelques centaines de ms).
- **`m` absent = licence REFUSEE.** Les licences flottantes ont existe le temps
d'une seance et l'une d'elles est sortie ; les refuser en bloc est la **seule
facon de l'annuler**. Un schema hors ligne n'a pas de revocation — rien ne
peut aller dire a un exe deja installe qu'une cle ne vaut plus — donc la seule
prise qui reste est de cesser d'accepter toute une classe de cles. Le cout
assume : plus de licence de site, une cle par poste. `-Floating` a ete retire
du script, qui ne peut donc plus fabriquer une cle que l'exe rejetterait.
- Un poste dont l'empreinte vaut `'unavailable'` (ni WMI ni MachineGuid) ne peut
plus etre licencie : le script n'accepte que 16 hexa. C'est le bon sens de
l'erreur — mieux vaut un poste qu'on ne peut pas licencier qu'une empreinte
degeneree qui ouvrirait partout.
- **Consequence de flux** : on ne peut plus emettre une licence avant que le
client ait installe. L'ecran d'activation affiche l'Installation ID, il
l'envoie, on emet. Changer la carte mere ou le disque systeme casse la
licence — la reparation est une reemission.
- Ce qui casse l'empreinte : carte mere remplacee, disque systeme remplace,
Windows reinstalle sur un autre volume, machine clonee.
- `SelfTestLicence` a l'`initialization` (comme `SelfTestCrypto`) rejoue un
vecteur fige signe par une **paire de test** dont la privee a ete jetee :
licence valable acceptee, un octet retourne dans la charge utile refuse.
`VerifyAgainstKey` prend la cle publique en parametre exactement pour que
l'auto-test n'ait pas besoin d'un second jeu de constantes de production.
### Ecran d'activation au demarrage
`ensureLicence()` (js/app.js) est appele **en tete d'`init()`**, avant tout le
reste : un ecran d'authentification qui s'affiche puis se fait recouvrir
donnerait l'impression que le coffre etait accessible. Il ne rend la main que
sur une licence acceptee — pas de bouton « plus tard ».
- Balisage `#licenceScreen` dans index.html, **place apres `#authScreen`** et
reprenant ses classes (`.auth-screen`, `.auth-card`, `.auth-hint`,
`.auth-form`) : zero style nouveau, sauf `#licenceScreen { z-index: 300 }`
pour passer au-dessus du bandeau (100) et du voile d'attente (200).
- Pont : `cmd://licence/status` et `cmd://licence/activate?key=`, reponse par
`Bridge.onLicenceResult(ok, porteur)`. La cle est du base64url plus un point,
donc rien a encoder de special sur le trajet.
- **Le statut est demande deux fois** avant de montrer l'ecran : un demarrage a
froid lent peut manquer la fenetre de 3 s, et le silence se lirait comme un
refus — presenter l'activation a quelqu'un qui a deja paye coute plus cher
qu'un second appel.
- Une cle refusee ne dit **pas pourquoi** : signature invalide, recopie fautive
et charge utile trafiquee se ressemblent, et detailler n'aiderait que celui
qui essaie d'en fabriquer une. Le journal note le verdict, **jamais la cle**.
- Hors du navigateur hote (`Bridge.active` faux : dev en navigateur, harnais de
test) il n'y a pas de licence a verifier — la porte est ouverte.
- **Settings → Licence** affiche porteur, courriel, date d'emission et
Installation ID. Rempli par `applyLicenceInfo(st)` **une fois au demarrage**
(les deux chemins : deja licencie, et activation reussie) plutot que branche
sur le rendu des reglages — rien n'y change pendant une session, et le panneau
est deja dans le DOM, cache. Section masquee hors navigateur hote : une suite
de tirets ferait croire a une activation perdue.
- Les champs sont **echappes avant interpolation** dans `ExecuteJavaScript`,
bien qu'ils viennent d'une charge utile signee : signee ne veut pas dire
inoffensive, c'est l'editeur qui les a tapes au moment d'emettre.
- Piege Delphi rencontre : **une routine imbriquee ne se declare pas au milieu
d'un corps de procedure** (seulement dans la section de declaration). En
glisser une casse l'inference des methodes anonymes de tout ce qui suit —
cinq `TThread.Queue` sans rapport se sont mis a ne plus compiler.
**Scenario d'essai** : [LICENCE-TESTS.md](LICENCE-TESTS.md) — cinq series, dont
une (D) dediee a la stabilite de l'empreinte, qui est le defaut deja rencontre.
La remise a zero passe par le bouton **Deactivate (debug)** au bas de
Settings -> Licence, revele seulement quand `config.txt` existe a cote de l'exe
(`cmd://licence/clear`, inerte sans lui). Il donne aussi son unique appelant a
`PM.Licence.ClearLicence`, et evite d'avoir a supprimer `prefs.bin` — qui
desactive mais emporte le mode de deverrouillage, le nom retenu et la sync.
### Cote Android : differe, et le jour venu on reutilisera la licence desktop
**Rien n'est fait, et c'est deliberе** — l'APK ne part chez personne aujourd'hui,
on l'installe soi-meme par adb. Un dispositif de licences sans destinataire est
du code qui vieillit sans jamais s'executer chez quelqu'un.
Le jour ou l'APK partira chez des clients, **ne pas refaire un second dispositif**.
Trois raisons, a garder pour ne pas les re-deriver :
- **L'elegance du cote Windows ne se transporte pas.** Elle venait de ce que
.NET sur Windows EST CNG : la signature `r||s` brute est deja celle qu'attend
`BCryptVerifySignature`. **Java attend du DER** — il faudrait emettre deux
formats ou convertir, plus un pont JNI vers `java.security.Signature`. La
moitie penible qu'on avait evitee reviendrait.
- **Pas d'empreinte materielle solide sur Android.** Plus d'IMEI ni de numero de
serie sans permission privilegiee ; il reste `ANDROID_ID`, **remis a zero par
une reinitialisation d'usine** — donc des clients legitimes qui perdent leur
licence sans rien avoir fait. C'est le defaut couteux, celui qui refuse
quelqu'un qui a paye.
- **Un APK se contourne bien plus facilement qu'un exe.** `repack-apk.ps1` en
est la demonstration : decompiler, retirer un test, resigner est de la routine
outillee.
**La forme retenue** : l'instantane publie par le poste porte la licence, et
l'APK exige d'y en trouver une. Le telephone lit deja cet instantane — aucun
nouveau format d'emission, aucune empreinte Android, aucune licence
supplementaire a fabriquer.
**Et l'APK n'a PAS a verifier la signature** : il constate la presence, rien de
plus. Payer le DER et le JNI pour durcir un controle que le repaquetage retire
en dix minutes serait le mauvais echange. Le but est de tenir le mobile attache
a un poste paye, pas de resister a une attaque — l'APK ne peut pas resister, et
faire semblant couterait plus que de l'admettre.
**ORDRE OBLIGATOIRE.** Tant que `LicencePublicKeyBlob` est a zero, aucune
licence n'est valide et l'ecran d'activation est **infranchissable** — l'exe
construit ne s'ouvre plus. Poser la cle publique AVANT de deployer :
`-Init` → coller les 72 octets → reconstruire → `-For` pour s'emettre une
licence a soi.
**Ce que ca arrete** : sans la cle privee, personne ne fabrique de licence
valable. **Ce que ca n'arrete pas** : rapiecer l'exe pour sauter l'appel — vrai
de tout schema hors ligne. La seule parade reelle serait de deporter une
fonction sur un serveur, ce qui contredit le « 100 % hors ligne ».
## Debug mode ## Debug mode
Fichier `config.txt` à côté de l'exe (tous champs optionnels) : Fichier `config.txt` à côté de l'exe (tous champs optionnels) :
``` ```
port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535) port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535)
# occupé = jusqu'à 3 essais, replis en éphémère
debug=true # affiche PanelTop (Start/Stop, log memo) debug=true # affiche PanelTop (Start/Stop, log memo)
require_token=false # désactive le token validation (curl/Postman direct) require_token=false # désactive le token validation (curl/Postman direct)
require_process_check=false # désactive le PID-on-socket check require_process_check=false # désactive le PID-on-socket check
@@ -173,7 +490,17 @@ Le serveur Indy n'accepte une requête que si elle a :
- OU cookie `pm_token=<token>; HttpOnly; SameSite=Strict; Path=/` - OU cookie `pm_token=<token>; HttpOnly; SameSite=Strict; Path=/`
Au start : Au start :
- Port aléatoire dans `[49152, 65535]` si pas configuré - Port aléatoire dans `[49152, 65535]` si pas configuré. Tiré de
`PM.Crypto.RandomBytes` (BCryptGenRandom), **pas** de `Random` : `Randomize`
n'est appelé nulle part, donc `Random` rendait la même suite à chaque
lancement — le port « aléatoire » était en réalité le même tous les jours, ce
qui vidait de son sens l'aléatoire censé compliquer un balayage.
- **Port occupé → jusqu'à 3 essais** (`BindAttempts`). Le premier honore le port
demandé ; les suivants prennent un éphémère. Se lier EST le test : un sondage
préalable serait plus faible (un service qui écoute sans répondre passerait
inaperçu) et sujet à une course (le port peut être pris entre le sondage et le
lien). Chaque déplacement est journalisé — un serveur qui change de port en
silence est pire qu'un serveur qui refuse de démarrer.
- Token random 32-chars hex généré - Token random 32-chars hex généré
- WebView2 navigate vers `http://127.0.0.1:{port}/index.html?pmt={token}` - WebView2 navigate vers `http://127.0.0.1:{port}/index.html?pmt={token}`
- JS strip le `?pmt=...` du URL bar via `history.replaceState` - JS strip le `?pmt=...` du URL bar via `history.replaceState`
@@ -262,8 +589,22 @@ La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).
`Bridge.onFaviconResult(reqId, host, dataUri)` `Bridge.onFaviconResult(reqId, host, dataUri)`
- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint - Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint
dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet
(qui re-PUT le password chiffré) (qui re-PUT le password chiffré). Body `{icon_b64, custom:bool}`.
- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache") - **Provenance `icon_custom INTEGER`** (0 = favicon auto-fetchée = cache
jetable, 1 = upload utilisateur). **Invariant : une icône custom n'est
JAMAIS touchée par une action automatique.** `saveEntryIcon(id, uri,
isCustom)` pose le flag (upload→1, fetch/remove→0). `backfillFavicons`
skippe `icon_custom` (même sur "Re-fetch all"), `DELETE /entries/icons/all`
filtre `COALESCE(icon_custom,0)=0`. Seul l'utilisateur change/retire une
custom, par entrée (boutons Upload/Remove du slideover). `GET /entries`
echoe `icon_custom`. **Propagé** de bout en bout : export manuel,
auto-backup, snapshot de sync l'écrivent ; `encryptImportEntry` le porte
dans le body ; les INSERT **create + bulk-import** le lisent
(`GetValue<Boolean>('icon_custom', False)` → 0 par défaut si absent, ex.
CSV étranger). Round-trip export→import et sync préservent donc la
provenance. Une icône vide (`icon_b64=''`) force toujours `icon_custom=0`.
- Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear
cache") — préserve les uploads custom
- Render : `entry-avatar` contient `<img class="entry-avatar-img">` si - Render : `entry-avatar` contient `<img class="entry-avatar-img">` si
`icon_b64`, sinon fallback aux initials. `onerror` repasse aux `icon_b64`, sinon fallback aux initials. `onerror` repasse aux
initials si la data URI est corrompue initials si la data URI est corrompue
@@ -476,6 +817,17 @@ retirée** (site='' quand chiffré) — le client la fait. `folder` reste en cla
(requête serveur de réassignation sur delete-folder). Reste en clair : (requête serveur de réassignation sur delete-folder). Reste en clair :
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps. `folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
**La corbeille voyage dans les sauvegardes, pas dans la sync** (2026-08-03) :
`buildBackupEntry` (js/app.import.js) est le point unique qui met en forme une
entrée pour un conteneur — partagé par l'export manuel **et** l'auto-backup, qui
en portaient deux copies. Les deux exportent `state.entries` **+**
`state.trashed`, avec `deleted` / `deleted_at` ; `deleted_at` est préservé pour
que l'auto-purge reprenne où elle en était au lieu de repartir à zéro (d'où son
ajout à l'écho de `GET /entries`). `buildSyncSnapshot` **n'utilise pas** ce
helper et n'envoie que le vivant : la corbeille est device-local, et republier
des lignes supprimées les dupliquerait (cf. le bug F8 où 30 entrées mises à la
corbeille revenaient vivantes, deux lignes pour un même uuid).
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours** Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
sur certaines actions : sur certaines actions :
@@ -567,6 +919,21 @@ Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
hotkeys autofill, etc. **Device-only** (localStorage seulement) : hotkeys autofill, etc. **Device-only** (localStorage seulement) :
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` `quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). (toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
Ces trois-là sont **device-only mais pas localStorage-only** : chacun est
miroité en pref DPAPI, sinon le changement de port les ramène à leur défaut
à chaque lancement (`autofillEnabled` revenait ainsi sur ON une fois
désactivé). Lu dans `enterApp` juste avant `autofillPushHotkeys`, la copie
DPAPI faisant autorité.
**Piège : tout réglage lu AVANT le login doit être en pref DPAPI.**
`settings_json` demande une session, et `localStorage` est vidé à chaque
lancement (port éphémère = nouvelle origine). Un réglage qui ne vit que
dans ces deux-là est systématiquement à sa valeur par défaut sur l'écran
d'auth. C'est ce qui faisait retomber un coffre en mode PIN sur le mot de
passe maître à chaque démarrage : `unlockMode` est donc **miroité en pref
DPAPI** (écrit au changement + dans `loadServerSettings`, relu par
`showAuth`), la copie DPAPI faisant autorité au boot. Même raison pour
`rememberedUsername`.
## Sync (WebDAV, auto-merge) ## Sync (WebDAV, auto-merge)
@@ -697,7 +1064,8 @@ persisted to `argon2_m/t/p`). `/login/challenge` returns them via
## PIN unlock ## PIN unlock
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
Unlock. Three modes (`state.unlockMode`, synced via `settings_json`) : Unlock. Three modes (`state.unlockMode`, dans `settings_json` **et** en pref
DPAPI) :
- `pw` — master password only (legacy, default) - `pw` — master password only (legacy, default)
- `pin` — PIN unlocks the vault on this device - `pin` — PIN unlocks the vault on this device
@@ -904,6 +1272,27 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche = brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
toutes les options, input = frappe libre). PAS un `<datalist>` natif toutes les options, input = frappe libre). PAS un `<datalist>` natif
(il filtre au texte tapé → n'affiche que ce qui matche, déroutant). (il filtre au texte tapé → n'affiche que ce qui matche, déroutant).
- **Champ Notes multiligne** (`field.multiline`) : bouton *Add note* du
panneau d'édition → champ `textarea` **secret par défaut**, masqué via
`-webkit-text-security` (un `textarea` n'a pas de `type=password`).
Hauteur dérivée du contenu (`autoSizeTextarea`, plafond 320 px) plutôt
que mémorisée — la rangée est reconstruite à chaque ouverture et
`localStorage` ne survit pas au changement de port.
**Pas de colonne `notes_enc` dédiée** : le corps d'une entrée *note* EST
`encrypted_password`, donc un vrai champ notes aurait exigé une nouvelle
colonne chiffrée + les 7 points de passage ci-dessus ; `custom_fields`
est déjà porté par rotation / duplicate / export-import / sync.
**Piège** : les champs personnalisés sont reconstruits **clé par clé** à
l'ouverture (normalisation) et au Save, et chacun jette ce qu'il ne
nomme pas → toute nouvelle clé (`multiline`, comme `options` avant elle)
doit être portée dans les **deux**, sinon elle disparaît silencieusement.
- **Rangée de champ personnalisé** : `libellé | valeur (œil dedans) | ⋮`.
L'œil n'existe que sur un champ secret (sa présence EST l'indicateur) ;
*Copy value*, *Store as secret* / *Make public* et *Remove field* sont
dans le menu `.entry-kebab-menu` réutilisé des cartes (la fermeture au
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
panneau et perd le focus + la hauteur auto du multiligne.
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par - **Settings search** : `#settingsSearch` filtre les `.setting-row` par
texte ; match sur le label de section garde toute la section. Esc dans texte ; match sur le label de section garde toute la section. Esc dans
la search avec query = clear ; Esc vide = ferme Settings. la search avec query = clear ; Esc vide = ferme Settings.
@@ -951,10 +1340,17 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
assumé device-local (purge de cache favicon). **`favorite` et `pinned` assumé device-local (purge de cache favicon). **`favorite` et `pinned`
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** : (toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
2026-07-11) — comme l'avatar. `favorite` est quand même dans l'auto-backup + 2026-07-11) — comme l'avatar. En revanche ils **round-trippent** par
export/import (round-trip local), juste pas propagé par le sync. Si un jour on export/import + auto-backup (restaurer une sauvegarde rend la liste telle
veut les synchroniser : ajouter au snapshot + bumper `updated_at` sur le toggle quelle) : container → `parseEntriesFromJSON` → `encryptImportEntry` →
+ appliquer au merge. INSERT create + bulk-import (`GetValue<Boolean>(…, False)` → 0 si absent,
ex. CSV étranger). **C'est l'omission dans le snapshot de sync qui les garde
device-local** — `encryptImportEntry` fait un passthrough générique, donc
les remettre dans `buildSyncSnapshot` suffirait à les propager (+ bumper
`updated_at` sur le toggle + appliquer au merge). Piège historique : le
snapshot écrivait `favorite` mais **rien ne le relisait** (ni parser, ni
body, ni INSERT) → l'export semblait le porter alors que l'import le
perdait, et `pinned` n'était nulle part.
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at` - **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` / `FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
@@ -999,7 +1395,11 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
## Git ## Git
- Pas de remote configuré (dev local) - Remote : `origin` = `https://git.ai-agents4you.com/Zaki/Password-Manager.git`
(Gitea auto-heberge, le meme serveur que celui qui sert de cible de sync).
`main` suit `origin/main`, mais **rien n'est pousse automatiquement** — les
commits s'accumulent en local et partent quand on le demande. Au 2026-09-06,
427 commits d'avance.
- `vault.db`, `*.db-shm`, `*.db-wal`, `delphi-backend/Win32/`, `*.dcu`, - `vault.db`, `*.db-shm`, `*.db-wal`, `delphi-backend/Win32/`, `*.dcu`,
et les artefacts BuildAssets **générés** `assets.res`/`assets.rc`/`assets.inc` et les artefacts BuildAssets **générés** `assets.res`/`assets.rc`/`assets.inc`
gitignored (régénérés avant chaque build Delphi, puis linkés dans l'exe — gitignored (régénérés avant chaque build Delphi, puis linkés dans l'exe —
+561
View File
@@ -182,6 +182,13 @@ stricte ; incohérence de **format** pré-existante (serveur `'yyyy-mm-dd
hh:nn:ss'` vs export JS `toISOString()` avec `T…Z`) sur les rares rows sans hh:nn:ss'` vs export JS `toISOString()` avec `T…Z`) sur les rares rows sans
`created_at`. `created_at`.
> ⚠️ **MAJ 2026-07-16 — « Tout est désormais UTC » était inexact, puis corrigé.**
> La passe de substitution avait raté **`PM.Session.CreateSession`**
> (`FormatDateTime(..., IncHour(Now, 24))`, heure locale). ✅ **Corrigé en
> §6c-2** (`IncHour(NowUTC, 24)` + `Authenticate` sur `NowUTC`). C'était le
> dernier `FormatDateTime(..., Now)` du backend → §2.2 est maintenant réellement
> clos.
### 2.3 🟡 `catch (_) {}` silencieux en cascade ### 2.3 🟡 `catch (_) {}` silencieux en cascade
Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`, Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`,
@@ -410,6 +417,551 @@ invisibles, perf et code mort. Rien de bloquant ; classé par type.
--- ---
## 6c. Revue backend Delphi (2026-07-16) — correctifs proposés, NON appliqués
Passe manuelle sur les ~10 500 lignes Delphi (`Source/` + `Handlers/` + `.dpr`),
axée crypto/auth/session/HTTP/SQL. Les correctifs ci-dessous sont **écrits mais
pas appliqués** — chacun est autonome.
**Sain, vérifié (pour ne pas re-chasser)** : aucune injection SQL (les seuls SQL
concaténés sont `PRAGMA table_info(...)` / `ALTER TABLE` dans `PM.Database`, sur
constantes internes) ; autorisation scopée par `user_id` **partout** sans
exception ; le `SET` dynamique de [Folders.pas:199](delphi-backend/Handlers/PM.Handler.Folders.pas:199)
est assemblé de littéraux fixes, pas d'input ; les routes non authentifiées sont
exactement les bonnes (register, login, challenge, ping, redeem, stub passkey) ;
`ValidateConnectingProcess` échoue **fermé** si le lookup PID rate ; primitives
crypto correctes + auto-testées à l'init. La fuite d'énumération de comptes de
`/login/challenge` est **documentée et assumée** ([Auth.pas:1189](delphi-backend/Handlers/PM.Handler.Auth.pas:1189))
→ pas comptée comme finding.
---
### 6c-1. 🔴 `/migrate-kdf` ne re-chiffre que 1 blob sur 8 — perte de données
> ✅ **RÉSOLU 2026-07-16 — chemin désactivé (option ultra).** Les 2 call-sites
> qui déclenchaient l'auto-migration au login/reauth
> ([app.js:1246, 1443](js/app.js)) sont retirés + la fonction morte
> `runKdfMigration` supprimée. Sans le client qui orchestre le swap de clé,
> l'endpoint serveur ne peut plus corrompre (il ne fait qu'un UPDATE de ce
> qu'on lui envoie) → laissé inerte, non touché. **DB vérifiée avant fix : les 2
> comptes sont déjà à 600k**, le chemin n'a donc jamais mordu. Un compte legacy
> <600k s'upgrade désormais via **Change Master Password** (qui re-chiffre les
> 8 blobs + attachments correctement et passe à Argon2id). §6c-4 (brick d'un
> compte -v2 par ce même endpoint) devient également inatteignable.
**C'est le seul vrai bug de perte de données de cette passe.**
La migration KDF passe les itérations 100k → 600k, donc **change la clé AES
dérivée** — puis [app.js:1035](js/app.js:1035) bascule `state.cryptoKey = newKey`.
Or [`runKdfMigration`](js/app.js:961) ne re-chiffre que `encrypted_password`/`iv`,
et [`HandleMigrateKdf`](delphi-backend/Handlers/PM.Handler.Auth.pas:795) n'UPDATE
que ces deux colonnes.
Restent chiffrés sous l'**ancienne** clé, donc définitivement illisibles :
`totp_secret`, `custom_fields`, `username_enc`, `site_enc`, `title_enc`,
`tags_enc`, `template_enc` (+ leurs IV), et les attachments.
Le contraste est net avec [`doChangeMasterPassword`](js/app.js:6106) /
[`HandleChangeMasterPassword`](delphi-backend/Handlers/PM.Handler.Auth.pas:1038),
qui affrontent **exactement la même situation** (changement de clé) et traitent
correctement les 8 colonnes. C'est une nouvelle instance de la classe de bug
décrite en **§3.3** — et la checklist « Entry payload » du CLAUDE.md cite
`doChangeMasterPassword` mais **pas** `runKdfMigration` : voilà par où c'est passé.
**Symptôme** : sournois, pas bruyant. [`decryptEntryMeta`](js/app.js:1502) ignore
silencieusement les champs qui ne déchiffrent pas (`if (v !== '[ERROR]') e[f] = v`)
→ titres/usernames deviennent `undefined` à l'écran, puis le premier save réécrit
ce vide par-dessus l'ancien ciphertext. Là c'est irréversible.
**Portée** : uniquement les comptes à `kdf_iterations < 600000`. Les inscriptions
partent directement à 600k ([app.js:1283](js/app.js:1283)) → legacy uniquement.
**Non vérifié** s'il en reste : aucun `vault.db` sur la machine de revue, pas de
CLI sqlite3. À trancher par `SELECT username, kdf_iterations, hash_algo FROM users;`
— si tout est à 600k, le bug n'a jamais mordu, mais le chemin reste **armé** et
auto-déclenché par signal serveur.
#### Correctif recommandé — extraire un helper partagé
Recopier la boucle de `doChangeMasterPassword` dans `runKdfMigration` serait
reproduire la cause (duplication, §3.3). La boucle
[app.js:6115-6179](js/app.js:6115) est **déjà exactement** la logique voulue :
l'extraire et l'appeler des deux côtés.
```js
// js/app.js — nouveau helper, unique source de vérité du re-chiffrement.
// Prend la clé courante (oldKey) et la cible (newKey), retourne le payload
// serveur. N'écrit RIEN dans state : l'appelant décide quand basculer.
async function reEncryptEntriesForNewKey(oldKey, newKey, onProgress) {
const out = [];
let done = 0;
for (const e of state.entries) {
done++;
if (onProgress) onProgress(done, state.entries.length);
state.cryptoKey = oldKey;
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') throw new Error('Could not decrypt entry id=' + e.id);
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
let totpEnc = '', totpIv = '';
if (e.totp_secret && e.totp_iv) {
state.cryptoKey = oldKey;
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
state.cryptoKey = newKey;
if (plainTotp !== '[ERROR]') {
const t = await encryptPwd(plainTotp);
totpEnc = t.encrypted; totpIv = t.iv;
}
}
let cfEnc = '', cfIv = '';
if (e.custom_fields && e.custom_fields_iv) {
state.cryptoKey = oldKey;
const plainCf = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
state.cryptoKey = newKey;
if (plainCf.length > 0) {
const c = await encryptCustomFields(plainCf);
cfEnc = c.encrypted; cfIv = c.iv;
}
}
state.cryptoKey = newKey;
const meta = {};
for (const f of ENCRYPTED_META_FIELDS) {
if (e[f]) {
const c = await encryptPwd(e[f]);
meta[f + '_enc'] = c.encrypted;
meta[f + '_iv'] = c.iv;
} else {
meta[f + '_enc'] = '';
meta[f + '_iv'] = '';
}
}
out.push({
id: e.id,
encrypted_password: re.encrypted, iv: re.iv,
totp_secret: totpEnc, totp_iv: totpIv,
custom_fields: cfEnc, custom_fields_iv: cfIv,
...meta,
});
} finally {
state.cryptoKey = oldKey; // restauré tant que le serveur n'a pas confirmé
}
}
return out;
}
```
`doChangeMasterPassword` remplace sa boucle par un appel ; `runKdfMigration`
remplace la sienne :
```js
// js/app.js — dans runKdfMigration, branche `if (kdfChange)`
newKey = await deriveKey(masterPwd, state.salt, toIters);
newCiphertexts = await reEncryptEntriesForNewKey(state.cryptoKey, newKey);
```
⚠️ **Les attachments restent à traiter** : `doChangeMasterPassword` les gère dans
une boucle séparée *après* le round-trip serveur (GET blob → décrypt oldKey →
encrypt newKey → `PUT /attachments/:id`). `runKdfMigration` ne l'a pas du tout.
Même extraction à faire (`reEncryptAttachmentsForNewKey(oldKey, newKey)`), appelée
des deux côtés après le commit.
#### Côté Delphi — aligner l'UPDATE sur celui de la rotation
```pascal
// PM.Handler.Auth.pas — HandleMigrateKdf, remplace l'UPDATE de la ligne ~795.
// Colonnes identiques à HandleChangeMasterPassword (ligne ~1039) : c'est le
// même événement (la clé change), donc le même jeu de blobs doit voyager.
LQ.SQL.Text :=
'UPDATE vault_entries SET ' +
' encrypted_password = :ep, iv = :iv, ' +
' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' username_enc = :uenc, username_iv = :uiv, ' +
' site_enc = :senc, site_iv = :siv, ' +
' title_enc = :tenc, title_iv = :tiv2, ' +
' tags_enc = :genc, tags_iv = :giv, ' +
' template_enc = :tplenc, template_iv = :tpliv, ' +
' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid';
```
…avec la même lecture de champs + binding que
[Auth.pas:1051-1077](delphi-backend/Handlers/PM.Handler.Auth.pas:1051).
#### Alternative stratégique (plus propre, plus de risque)
Un « migrate-kdf » **est** une rotation de master pw vers le même mot de passe.
Faire déléguer `runKdfMigration` à `/change-master-password` (nouveau salt +
`argon2id-v2`) supprimerait ce chemin dupliqué *et* ferait basculer les comptes
legacy sur Argon2id d'un coup — ce que §1.2 attend de toute façon. Contrepartie :
`HandleChangeMasterPassword` fait `DeleteAllUserSessions` + `CreateSession`
([Auth.pas:1161](delphi-backend/Handlers/PM.Handler.Auth.pas:1161)) et le CLAUDE.md
impose le wipe du blob PIN → une migration silencieuse de fond deviendrait
visible. À arbitrer ; le correctif chirurgical ci-dessus est le chemin bas risque.
#### Test de non-régression suggéré
La suite `js/tests/` couvre déjà le round-trip crypto. Ajouter : *entry avec TOTP
+ custom_fields + méta chiffrée → `reEncryptEntriesForNewKey` → tout déchiffre
sous newKey*. C'est le filet qui aurait attrapé ça.
---
### 6c-2. 🟠 Expiry de session écrite en local, lue en UTC (corrige §2.2)
> ✅ **RÉSOLU 2026-07-16.** `CreateSession` écrit `IncHour(NowUTC, 24)` et
> `Authenticate` compare `LExpires < NowUTC` ([PM.Session.pas](delphi-backend/Source/PM.Session.pas)).
> Les 3 lecteurs (Authenticate, RequireCSRF, CleanupExpired) sont désormais tous
> sur UTC. `NowUTC` + `System.DateUtils` déjà dans les uses → aucun uses touché.
> §2.2 est maintenant réellement clos (c'était le dernier `FormatDateTime(...,
> Now)` du backend). Sessions existantes : expirent sous 24 h, purgées au boot.
[`CreateSession`](delphi-backend/Source/PM.Session.pas:155) écrit
`FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24))` — heure **locale**.
C'est le dernier `FormatDateTime(..., Now)` du backend et il contredit la règle
du CLAUDE.md (« Timestamps = UTC partout … JAMAIS `FormatDateTime(..., Now)` »).
`NowUTCStr` existe et est utilisé partout ailleurs.
Trois lecteurs, deux bases de temps :
| Lecteur | Base | Cohérent ? |
|---|---|---|
| [`Authenticate`](delphi-backend/Source/PM.Session.pas:89) | `Now` (local) | oui |
| [`RequireCSRF`](delphi-backend/Source/PM.Session.pas:122) | `datetime('now')` (UTC) | **non** |
| [`CleanupExpired`](delphi-backend/Source/PM.Database.pas:469) | `datetime('now')` (UTC) | **non** |
**Pourquoi ça ne s'est jamais vu** : en UTC+1/+2 le décalage va dans le sens
inoffensif — `Authenticate` coupe à 24 h avant que la fenêtre CSRF (26 h) ne
serve. En **UTC-5** la fenêtre CSRF tombe à **19 h** : entre 19 h et 24 h la
session s'authentifie normalement, les GET passent, et **toute écriture répond
403 « Invalid CSRF token »**. Scénario plausible : « Start with Windows » + quick
unlock, machine laissée allumée plus d'une journée.
```pascal
// PM.Session.pas — CreateSession (~ligne 155)
// UTC comme partout ailleurs : RequireCSRF et CleanupExpired comparent contre
// SQLite datetime('now') qui EST UTC.
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(NowUTC, 24));
```
```pascal
// PM.Session.pas — Authenticate (~ligne 89)
// Doit suivre : expires_at est désormais UTC.
if (LExpires <> 0) and (LExpires < NowUTC) then
```
`PM.Database` (qui expose `NowUTC`) est déjà dans le `uses` de l'interface,
`System.DateUtils` (pour `IncHour`) déjà dans celui de l'implémentation → aucun
`uses` à toucher. Les trois lecteurs s'alignent alors sur UTC.
**Note migration** : les sessions déjà en base gardent leur `expires_at` local.
Sans importance — elles expirent sous 24 h, et `CleanupExpired` les purge au
prochain démarrage.
---
### 6c-3. 🟡 Le CSRF est validé contre la mauvaise session
> ✅ **RÉSOLU 2026-07-16.** `RequireCSRF` lit désormais `csrf_token WHERE
> token_hash = :th AND user_id = :uid` (la session présentée), plus de
> `ORDER BY created_at DESC LIMIT 1`. `ExtractBearerToken`/`SHA256Hex` déjà
> utilisés par `Authenticate` dans la même unité ; signature inchangée, aucun
> call-site touché. Le re-test d'expiry est retiré (Authenticate tourne avant
> et l'a déjà fait, désormais sur la même base UTC après §6c-2).
[`RequireCSRF`](delphi-backend/Source/PM.Session.pas:120) cherche le
`csrf_token` par `user_id ORDER BY created_at DESC LIMIT 1` — la session la plus
récente, **pas celle réellement présentée**. Deux conséquences :
- `created_at` a une résolution d'**une seconde** : deux sessions créées dans la
même seconde donnent une égalité que SQLite tranche arbitrairement → il peut
lire le token de l'ancienne → **403 fantôme**.
- Conceptuellement, le contrôle devient « connais-tu *un* CSRF valide pour cet
utilisateur » au lieu de « celui lié à ta session ».
`Authenticate` a **déjà** localisé la bonne ligne par `token_hash` : lire
`csrf_token` depuis cette ligne-là est plus simple *et* correct. La signature ne
change pas → aucun call-site à toucher.
```pascal
// PM.Session.pas — RequireCSRF
var
LSubmitted, LStored, LTokenHash: string;
LQ: TFDQuery;
begin
if SameText(ARequest.Command, 'GET') then Exit;
LSubmitted := ARequest.RawHeaders.Values['X-CSRF-Token'];
if LSubmitted = '' then
begin
TJSONHelper.SendError(AResponse, 403, 'Missing CSRF token');
raise ESessionRejected.Create('missing csrf');
end;
// Lier le CSRF à la session PRÉSENTÉE, pas à la plus récente du user.
// AUserId reste en filtre (defense in depth).
LTokenHash := SHA256Hex(ExtractBearerToken(ARequest));
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT csrf_token FROM sessions ' +
'WHERE token_hash = :th AND user_id = :uid';
LQ.ParamByName('th').AsString := LTokenHash;
LQ.ParamByName('uid').AsInteger := AUserId;
LQ.Open;
// ... suite inchangée
```
L'expiry n'a plus à être re-testée ici : `Authenticate` tourne toujours avant et
l'a déjà fait (et après **6c-2** les deux sont sur la même base de temps).
---
### 6c-4. 🟡 `/migrate-kdf` rétrograderait un compte `-v2` (verrouillage définitif)
> ✅ **RÉSOLU 2026-07-16.** Au-delà de 6c-1 (client), l'endpoint serveur
> `HandleMigrateKdf` a été **vidé de ses ~165 lignes destructrices** et remplacé
> par un reject `410` après auth+CSRF ([Auth.pas:656](delphi-backend/Handlers/PM.Handler.Auth.pas)).
> Un appel isolé ne peut donc plus **ni corrompre** (6c-1) **ni brick** un compte
> -v2 (6c-4) : les deux sont fermés côté serveur, pas seulement « inatteignables
> par convention ». Le signal `kdfMigration` du login reste (vestigial, client
> l'ignore, endpoint = 410) — non retiré car câblé via un paramètre sur le
> hot-path login, churn sans bénéfice.
Le garde d'idempotence ([Auth.pas:722](delphi-backend/Handlers/PM.Handler.Auth.pas:722))
ne court-circuite que si l'algo vaut `pbkdf2-sha256`. Un compte `argon2id-v2` ou
`pbkdf2-sha256-v2` **passe au travers** et se fait réécrire en
`HASH_ALGO_CURRENT` ([Auth.pas:763](delphi-backend/Handlers/PM.Handler.Auth.pas:763)
et [785](delphi-backend/Handlers/PM.Handler.Auth.pas:785)) → la formule de
verifier change → **lockout définitif**.
Non atteignable aujourd'hui : le register envoie `kdfIterations: 600000`
([app.js:1296](js/app.js:1296)) donc le login ne signale jamais de migration pour
un compte v2 — et le commentaire [app.js:1016](js/app.js:1016) montre que le cas a
été raisonné. Mais l'endpoint est authentifié, CSRF'é et **armé** ; le garde coûte
4 lignes et rend la protection locale au lieu de dépendre de l'appelant.
```pascal
// PM.Handler.Auth.pas — HandleMigrateKdf, juste avant le garde d'idempotence
// existant (~ligne 722).
// Les schemes -v2 ont un verifier découplé : migrate-kdf ne sait pas les
// réécrire sans changer la formule → refuser au lieu de bricker le compte.
if LAlgo.EndsWith('-v2', True) then
begin
TJSONHelper.SendOK(AResponse, 'Already at target');
Exit;
end;
```
---
### 6c-5. 🔵 Fallback silencieux sur params Argon2 invalides
[Auth.pas:319](delphi-backend/Handlers/PM.Handler.Auth.pas:319) (register) et
[Auth.pas:994](delphi-backend/Handlers/PM.Handler.Auth.pas:994) (rotation) :
```pascal
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then ... else
LRegAlgo := HASH_ALGO_DECOUPLED; // ← fallback muet
```
Un client qui déclare `argon2id-v2` avec des params hors bornes est stocké en
**PBKDF2** sans un mot, alors qu'il a dérivé sa clé en **Argon2id** → au login
suivant `/login/challenge` renvoie `pbkdf2-sha256-v2`, le client dérive en PBKDF2,
verifier différent → **compte brické**. Le JS envoie toujours
`ARGON2_DEFAULT_PARAMS` (valides) donc ce n'est pas live ; échouer fort coûte
5 lignes.
```pascal
// Aux DEUX endroits : un client qui DEMANDE Argon2id avec des params hors
// bornes doit être refusé, pas silencieusement rétrogradé en PBKDF2.
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and (not LArgon.Valid) then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid argon2 params');
Exit;
end;
```
---
### 6c-6. 🔵 Le port « aléatoire » dépend d'un `Randomize` qui n'est pas le nôtre
[PM.HTTPServer.pas:136](delphi-backend/Source/PM.HTTPServer.pas:136) utilise
`Random()`, et le projet n'appelle **jamais** `Randomize` (vérifié : 0 occurrence
dans tout le `.dpr` + `.pas`). `RandSeed` vaut 0 par défaut → séquence
déterministe.
Empiriquement le port **varie** bien (le CLAUDE.md documente le wipe de
`localStorage` à chaque lancement, ce qui le prouve) → une unité RTL/Indy sème
`RandSeed` en initialisation. Ça marche donc **par accident** : dépendance
implicite, sur ce que le design traite comme une mesure de sécurité, alors que
`RandomBytes` (CNG) est déjà utilisé **6 lignes plus haut** pour le token.
```pascal
// PM.HTTPServer.pas — Start (~ligne 136)
// CSPRNG (CNG) plutôt que Random() : ne dépend pas d'un Randomize appelé par
// une unité tierce. 49152..65535 = 16384 valeurs, et 65536 mod 16384 = 0
// → pas de biais modulo.
if LRequestedPort = 0 then
begin
var LRnd := PM.Crypto.RandomBytes(2);
LRequestedPort := EphemeralPortMin +
(((LRnd[0] shl 8) or LRnd[1]) mod (EphemeralPortMax - EphemeralPortMin + 1));
end;
```
---
### 6c-7. 🔵 Comparaison du token d'accès : sous-chaîne + non constant-time
[PM.HTTPServer.pas:258](delphi-backend/Source/PM.HTTPServer.pas:258) :
```pascal
if (CookieHeader <> '') and (Pos(CookieName + '=' + FAccessToken, CookieHeader) > 0)
```
`Pos` matche **n'importe où** dans le header et accepte les **préfixes** :
`pm_token=<token>junk` passe, et un cookie nommé `xpm_token=<token>` aussi. Ni ça
ni le `=` de la ligne 262 ne sont constant-time, alors que `ConstantTimeEquals`
existe et sert déjà pour le CSRF. Impact faible derrière le PID check (il faut
déjà connaître le token), mais c'est une incohérence gratuite.
```pascal
// PM.HTTPServer.pas — parse réel du header Cookie plutôt qu'un Pos() sur la
// chaîne brute, + comparaison constant-time (comme RequireCSRF).
function TryGetCookie(const AHeader, AName: string; out AValue: string): Boolean;
var
LPairs: TArray<string>;
LPair, LName: string;
LEq: Integer;
begin
Result := False;
AValue := '';
LPairs := AHeader.Split([';']);
for LPair in LPairs do
begin
LEq := Pos('=', LPair);
if LEq <= 0 then Continue;
LName := Trim(Copy(LPair, 1, LEq - 1));
if SameText(LName, AName) then
begin
AValue := Trim(Copy(LPair, LEq + 1, MaxInt));
Exit(True);
end;
end;
end;
```
```pascal
// ValidateAccessToken — remplace les deux comparaisons
if TryGetCookie(CookieHeader, CookieName, LCookieVal) and
ConstantTimeEquals(LCookieVal, FAccessToken) then
Exit(True);
QueryToken := ARequest.Params.Values[QueryParamName];
if (QueryToken <> '') and ConstantTimeEquals(QueryToken, FAccessToken) then
begin
// ... Set-Cookie inchangé
```
(`PM.Crypto` est déjà dans le `uses` de `PM.HTTPServer` → rien à ajouter.)
---
### 6c-8. 🔵 Fuite mémoire sur chemin d'erreur
[Attachments.pas:292](delphi-backend/Handlers/PM.Handler.Attachments.pas:292) :
`LArr := TJSONArray.Create` est créé **avant** le `try/finally` → une exception
dans le bloc DB le fuit. `SendJSON` ne le libère que sur le chemin nominal.
```pascal
// PM.Handler.Attachments.pas — HandleListAllAttachments
LArr := TJSONArray.Create;
try
DB.Lock;
try
// ... remplissage inchangé
finally
DB.Unlock;
end;
except
LArr.Free;
raise;
end;
TJSONHelper.SendJSON(AResponse, LArr); // libère LArr (AOwnsObj=True)
```
---
### 6c-9. ❓ `PRAGMA foreign_keys` — à vérifier, non tranché
> ✅ **RÉSOLU 2026-07-16.** `FConn.Params.Values['ForeignKeys'] := 'On'` ajouté
> à la config de connexion FireDAC ([PM.Database.pas](delphi-backend/Source/PM.Database.pas)).
> Correct dans les deux cas (no-op si c'était déjà le défaut). **Vérif base
> avant fix : 0 attachment / 0 history orphelin** — soit le défaut FireDAC était
> déjà On, soit aucun delete-permanent-avec-attachments n'a encore eu lieu ;
> l'explicite lève le doute pour de bon.
`PRAGMA foreign_keys` n'est défini **nulle part**. Le schéma s'appuie pourtant sur
`ON DELETE CASCADE` : [Entries.pas:704](delphi-backend/Handlers/PM.Handler.Entries.pas:704)
(delete permanent) ne nettoie **ni** `entry_attachments` **ni**
`entries_password_history` à la main, et le CLAUDE.md affirme « FK cascade on user
+ entry delete ».
SQLite met `foreign_keys` à **OFF** par défaut. Le paramètre `ForeignKeys` de
FireDAC est *censé* défauter à `On` (ce qui sauverait la mise), mais **je n'ai pas
pu le confirmer** : les DCU ne sont pas greppables et il n'y avait pas de base pour
tester. Si c'était Off, chaque suppression définitive laisserait des blobs chiffrés
orphelins — exactement ce que `CompactIfBloated` tente de rattraper.
Deux façons de lever le doute :
```sql
-- Sur un vault.db réel, après avoir supprimé définitivement une entry
-- qui avait des attachments :
SELECT COUNT(*) FROM entry_attachments a
WHERE NOT EXISTS (SELECT 1 FROM vault_entries e WHERE e.id = a.entry_id);
-- > 0 → la cascade ne s'applique pas.
```
```pascal
// PM.Database.pas — TPMDatabase.Create, avec les autres Params (~ligne 82).
// Explicite = plus de dépendance à un défaut FireDAC non documenté chez nous.
FConn.Params.Values['ForeignKeys'] := 'On';
```
Le second est correct **dans les deux cas** (no-op si c'était déjà le défaut) →
à appliquer sans attendre la vérification.
---
### Ordre d'application suggéré
| # | Sévérité | Effort | État (2026-07-16) |
|---|---|---|---|
| **6c-1** | 🔴 | — | ✅ **Fait** — chemin migrate-kdf désactivé (option ultra) au lieu de réparé. |
| **6c-2** | 🟠 | — | ✅ **Fait** — `expires_at` en UTC. Ferme §2.2. |
| **6c-3** | 🟡 | — | ✅ **Fait** — CSRF lié à la session présentée (token_hash). |
| **6c-9** | ❓ | — | ✅ **Fait** — `ForeignKeys=On` explicite. |
| **6c-4** | 🟡 | — | ✅ **Fait** — endpoint migrate-kdf vidé + reject 410 (ferme 6c-1 & 6c-4 côté serveur). |
| **6c-5** | 🔵 | Trivial | À faire — 5 lignes × 2 endroits (fail-fast params Argon2). |
| **6c-6/7/8** | 🔵 | Faible | À faire — cohérence / hygiène, aucune urgence. |
Rappel build : tout edit JS impose `delphi-backend\assets\BuildAssets.cmd` **puis**
le build Delphi, sinon l'exe garde les anciens assets.
---
## 7. Verdict ## 7. Verdict
Pour un usage **personnel, local, mono-utilisateur**, le produit est Pour un usage **personnel, local, mono-utilisateur**, le produit est
@@ -425,3 +977,12 @@ verifier découplé (§1.1), Argon2id (§1.2), métadonnées chiffrées au repos
navigateur, Windows Hello, import KeePass, i18n). Le produit est passé de navigateur, Windows Hello, import KeePass, i18n). Le produit est passé de
« sûr pour du solo loopback » à « durci pour un modèle de menace DB/sync qui « sûr pour du solo loopback » à « durci pour un modèle de menace DB/sync qui
fuit ». fuit ».
**MAJ 2026-07-16** : la revue backend Delphi (**§6c**) ne remet pas en cause ce
verdict sur le **durcissement** — aucune injection, autorisation scopée partout,
routes non authentifiées correctes, crypto saine. Le **🔴 de perte de données**
(`/migrate-kdf` re-chiffrait 1 blob sur 8, **§6c-1**) est **traité** : chemin
désactivé (les 2 comptes étaient déjà à 600k, jamais mordu). Faits aussi :
expiry session UTC (**§6c-2**, clôt §2.2), CSRF lié à la bonne session
(**§6c-3**), `foreign_keys` explicite (**§6c-9**). Reste du confort/hygiène
(**§6c-5/6/7/8**), sans urgence.
+183
View File
@@ -0,0 +1,183 @@
# Système de licences — scénario d'essai
Écrit le **2026-09-05**, après la séance qui a posé la vérification hors ligne,
le liage au poste et l'écran d'activation.
Ce plan ne cherche pas à montrer que ça marche : **il cherche les endroits où ça
casserait sans prévenir**. Un système de licences échoue de deux façons
opposées, et les deux coûtent cher — refuser un client qui a payé, ou ouvrir à
quelqu'un qui n'a rien. Chaque essai ci-dessous vise l'une des deux.
Ce que la séance a déjà appris, et qui vaut d'être gardé en tête : **l'empreinte
du poste dépendait de l'ordre de démarrage.** Selon que WMI répondait ou non,
la même machine se présentait sous deux identités et refusait sa propre licence.
Rien dans une utilisation normale ne l'aurait montré. D'où la série D.
---
## Préparation
**L'outil de remise à zéro** : pose un fichier `config.txt` à côté de l'exe
(n'importe quel contenu, même vide). Un bouton rouge **Deactivate (debug)**
apparaît alors au bas de *Settings → Licence* : il efface la licence rangée et
rien d'autre. Sans `config.txt` le bouton n'apparaît pas, et la commande
sous-jacente est de toute façon inerte — un exe livré ne peut pas être
désactivé par ce chemin.
Ne supprime `%LOCALAPPDATA%\PMServer\prefs.bin` qu'en dernier recours : ça
désactive aussi, mais emporte le mode de déverrouillage, le nom retenu et les
réglages de sync.
**Ce qu'il faut sous la main** :
- ta licence valable, liée à ce poste ;
- l'Installation ID de ce poste (Settings → Licence, ou l'écran d'activation) ;
- `tools/licence.ps1` et la clé privée.
**Ordre** : `A → B → C → D → E`. La série A établit l'état de référence ; les
suivantes le cassent volontairement.
---
## A. Le chemin normal
1. Coffre déjà activé, lancer l'application.
**À voir** : l'écran d'activation **n'apparaît pas**. On tombe directement
sur l'écran de connexion.
2. Settings → Licence.
**À voir** : *Licensed to*, *Email*, *Issued* et *Installation ID* remplis.
Le nom affiché est celui que tu as tapé à l'émission — il vient de la charge
utile signée, pas d'un réglage local.
3. Fermer, relancer.
**À voir** : toujours pas d'écran d'activation. C'est ce qui prouve que la
licence est rangée et relue, pas seulement acceptée une fois.
---
## B. Ce qui doit être refusé
Chaque essai part de l'écran d'activation (licence effacée).
1. **Chaîne quelconque** : taper `nimportequoi`.
**À voir** : *This licence key is not valid*, **en rouge**. L'écran reste.
2. **Licence tronquée** : coller ta licence valable en retirant les dix derniers
caractères.
**À voir** : même refus. La signature ne tolère pas un octet.
3. **Charge utile trafiquée** : prendre ta licence, changer **un seul caractère
dans la partie avant le point** (le nom, l'adresse), coller.
**À voir** : même refus. C'est l'essai qui prouve que le contenu affiché dans
Settings ne peut pas être maquillé.
4. **Licence d'un autre poste** :
```
tools\licence.ps1 -For "x@y.z" -Name "Autre" -Machine "AAAA-BBBB-CCCC-DDDD"
```
coller le résultat.
**À voir** : refus. **C'est le cœur du liage** — une licence parfaitement
signée, mais pas pour cette machine.
5. **Licence d'une autre paire de clés** :
```
tools\licence.ps1 -Init -KeyFile "%TEMP%\faux.key"
tools\licence.ps1 -For "x@y.z" -Name "Faux" -Machine "<TON ID>" -KeyFile "%TEMP%\faux.key"
```
coller le résultat.
**À voir** : refus. **C'est le cœur de la signature** — la bonne machine, la
bonne forme, mais pas ta clé. Si cet essai passait, n'importe qui pourrait
fabriquer des licences.
Puis supprimer `%TEMP%\faux.key`.
6. **Ta vraie licence**, pour finir.
**À voir** : l'écran disparaît, on passe à la connexion.
---
## C. Ce qui ne doit PAS être refusé
Le risque symétrique, et le plus coûteux commercialement.
1. Coller ta licence **avec des espaces avant et après** (une sélection à la
souris en attrape presque toujours).
**À voir** : acceptée. Le code fait `Trim`.
2. Coller ta licence **avec un retour à la ligne** collé au bout.
**À voir** : acceptée.
3. Après activation, **redémarrer la machine** (pas seulement l'application).
**À voir** : toujours activée. Le port du serveur change à chaque
lancement — si la licence avait vécu dans `localStorage`, elle serait
perdue ici. C'est l'essai qui prouve le stockage DPAPI.
---
## D. L'empreinte du poste — la série qui a déjà attrapé un défaut
1. Noter l'Installation ID dans Settings → Licence.
2. Fermer, relancer, relire.
**À voir** : **exactement le même**. Un seul caractère de différence, et
toutes les licences de cette machine tomberaient au prochain lancement.
3. Relancer **cinq fois de suite**, en laissant la machine chargée (ouvrir
quelques applications) pour varier les temps de démarrage.
**À voir** : le même à chaque fois. C'est le défaut corrigé le 2026-09-05 :
l'empreinte dépendait de l'initialisation de COM par WebView2, donc de la
vitesse de démarrage.
4. **Démarrage à froid** : redémarrer Windows, lancer l'application en premier.
**À voir** : le même.
---
## E. La portée de l'activation
1. **Autre compte Windows** sur la même machine : ouvrir une session
différente, lancer l'application.
**À voir** : l'écran d'activation **réapparaît**, avec le **même**
Installation ID. Comportement voulu : la licence est rangée par utilisateur
(DPAPI), l'empreinte est celle de la machine. Ta licence y sera acceptée.
2. **Copier l'exe dans un autre dossier** et le lancer.
**À voir** : toujours activée. La licence ne vit pas à côté de l'exe.
3. **La mise à jour** — le cas qu'on vivra réellement. Remplacer l'exe d'une
installation existante par une nouvelle version, lancer.
**À voir** : toujours activée. **C'est l'essai le plus utile de la série** :
si une mise à jour désactivait, il faudrait réémettre une licence à chaque
livraison, et on ne s'en apercevrait qu'au premier client mécontent.
---
## Résultat de la première campagne — 2026-09-05
`A` `B` `C` `D` `E2` `E3` **passés**. Rien à corriger : la campagne n'a rien
trouvé, ce qui n'était pas acquis — la séance qui l'a précédée avait déjà sorti
trois défauts (empreinte dépendante de l'ordre de démarrage, message d'erreur en
cyan au lieu de rouge, licences flottantes acceptées).
**`E1` (autre compte Windows) n'a pas été joué** — faute d'un second compte sous
la main. Ce que le code dit qu'il donnerait : écran d'activation de retour, même
Installation ID, licence acceptée telle quelle. À éprouver le jour où un client
le signale, ou avant de vendre à quelqu'un qui a plusieurs sessions.
Deux observations de la campagne, gardées parce qu'elles se reproduiront :
- **`C3` a été plus probant que prévu.** Le port avait été changé à la main entre
les deux lancements (8766 → 51812), donc l'origine du navigateur a réellement
changé — exactement ce que l'essai veut prouver, mais pour de bon.
- **Un port éphémère dans le journal sans ligne de repli n'est pas un défaut** :
c'est `port=0` ou un `config.txt` absent. Le repli, lui, écrit toujours
*« Port N unavailable »* avant de se déplacer.
---
## Ce qu'aucun de ces essais ne prouve
À dire clairement, pour ne pas se croire protégé de ce qu'on ne l'est pas :
- **Rapiécer l'exe pour sauter la vérification reste possible.** C'est vrai de
tout schéma hors ligne. La signature empêche de *fabriquer* des licences, pas
de contourner le code qui les lit.
- **Il n'y a pas de révocation.** Une licence émise vaut pour toujours sur sa
machine. La seule annulation possible est de régénérer la paire et de
réémettre — ce qui casse toutes les autres.
- **Le brouillage des blocs embarqués n'est pas du chiffrement.** L'onglet
*Memory* de Process Explorer, un débogueur ou la réponse HTTP rendent toujours
le frontend en clair.
## Le défaut à reconnaître sans le provoquer
Si `$ObfSecret` (BuildAssets.ps1) et `AssetObfSecret` (PM.EmbeddedAssets.pas)
divergent, la magie `PMA1` tombe et le serveur **refuse de servir** :
`/index.html` en **404**, fenêtre vide. Ce n'est pas une panne de licence, c'est
un désaccord de build. Symptôme à ne pas confondre.
+706
View File
@@ -0,0 +1,706 @@
# Scénarios de test — écriture concurrente & fermeture de l'éditeur
Sessions du 2026-07-22. Tous les tests sont **manuels** (ils portent sur des
interactions UI que le harness `node:test` ne couvre pas). Rebuild obligatoire
avant de commencer : `delphi-backend\assets\BuildAssets.cmd` puis build Delphi.
Convention : **PRÉ** = état de départ, **ACTION**, **ATTENDU**. Un test qui
échoue = régression, noter le commit courant.
---
## Les 4 classes de bug
| # | Classe | Cause racine | Corrigé par |
|---|---|---|---|
| **A** | Écriture extérieure écrasée au Save | L'éditeur garde le payload chargé à l'ouverture et le re-poste **entier** ; toute écriture faite ailleurs entre-temps est perdue | `editorAbsorbChange` (d146fbe) |
| **B** | Éditeur fermé sans garde-fou | `closeSlideOver()` appelé sans condition + click-outside non filtré | `closeEditorForDeleted` (3dbce78), whitelist (e1ddc65) |
| **C** | PUT `/entries` sans `withEncryptedMeta` | Les colonnes en clair sont droppées côté serveur → le body ne contient rien de stockable et blanchit les `*_enc` | d146fbe |
| **D** | UI construite sans regarder la vue | Le menu kebab est partagé entre vues ; la corbeille n'était pas testée | d146fbe |
---
## A — Éditeur ouvert vs écriture extérieure
Préparer : 3 entrées `A`, `B`, `C`, dont `A` dans le dossier `Work` avec le tag
`old`.
### A1 · batch Add tag sur l'entrée éditée
- **PRÉ** : `A` ouverte dans l'éditeur, taper du texte dans NOTE/PASSWORD (ne pas sauver)
- **ACTION** : cocher `A` + `B` → barre de sélection → *Add tag* → `shared` → puis **Save** dans l'éditeur
- **ATTENDU** : le chip `shared` apparaît **immédiatement** dans l'éditeur ; après Save, `A` porte `old, shared` **et** la modif tapée. Le bouton Save reste visible (l'entrée était déjà dirty) mais le tag seul ne rend pas dirty.✅
### A2 · batch Add tag sur une **autre** entrée
- **PRÉ** : `A` ouverte + dirty
- **ACTION** : cocher `B` + `C` seulement → *Add tag* → `x` → Save dans l'éditeur
- **ATTENDU** : l'éditeur ne bouge pas (aucun chip ajouté), `A` conserve ses tags, `B`/`C` taguées.✅
### A3 · batch Move to folder sur l'entrée éditée
- **ACTION** : `A` ouverte + dirty → cocher `A` → *Move to folder…* → `Perso` → Save
- **ATTENDU** : le select Folder de l'éditeur bascule sur `Perso` tout seul ; après Save l'entrée est bien dans `Perso`.✅
### A4 · dossier choisi dans l'éditeur **puis** move extérieur
- **ACTION** : `A` ouverte, changer Folder → `Archive` (ne pas sauver) → cocher `A` → *Move to folder…* → `Perso` → Save
- **ATTENDU** : `Perso` gagne (action extérieure = intention la plus récente). Comportement documenté, pas un bug. ✅
### A5 · glisser une entrée sur un dossier (`moveEntryToFolder`)
- **ACTION** : `A` ouverte + dirty → glisser la ligne `A` sur `Perso` dans la sidebar → Save
- **ATTENDU** : identique à A3.✅
### A6 · glisser une **multi-sélection** sur un dossier (`moveEntriesToFolder`) ✅
- **ACTION** : cocher `A` + `B` → glisser sur `Perso` → recharger (F5 impossible : relancer l'app)
- **ATTENDU** : dossier changé **et** nom / username / site / tags **intacts**.
- **Historique** : ce chemin n'appelait pas `withEncryptedMeta` → il blanchissait les métadonnées chiffrées. Si des entrées ont été déplacées ainsi avant le fix, elles sont peut-être déjà vides — vérifier.
### A7 · suppression du dossier de l'entrée éditée
- **ACTION** : `A` (dossier `Work`) ouverte + dirty → sidebar → supprimer le dossier `Work` → Save
- **ATTENDU** : le select de l'éditeur passe à `All` ; après Save l'entrée est dans `All`, le dossier supprimé **ne réapparaît pas**.✅
### A8 · tags édités des deux côtés
- **ACTION** : `A` ouverte, ajouter le chip `mine` dans l'éditeur (ne pas sauver) → batch *Add tag* `shared` sur `A` → Save
- **ATTENDU** : `old, mine, shared` — aucun des trois perdu.✅
### A9 · favori / épingle pendant l'édition (non-régression)
- **ACTION** : `A` ouverte + dirty → cliquer ★ et 📌 sur la ligne `A` → Save
- **ATTENDU** : l'entrée reste favorite et épinglée (ces colonnes ne sont pas dans le PUT de l'éditeur).✅
### A10 · icône / pièce jointe pendant l'édition (non-régression)
- **ACTION** : `A` ouverte + dirty → Settings → *Fetch missing* favicons (ou upload d'icône) → Save
- **ATTENDU** : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅
### A10b · icône rafraîchie dans l'éditeur ouvert
- **ACTION** : `A` ouverte → Settings → *Fetch missing* / *Re-fetch all* favicons
- **ATTENDU** : l'aperçu d'icône **dans l'éditeur** se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅
### A11 · sync / import pendant que l'éditeur est ouvert ✅
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
- **ATTENDU** : modale « Entry changed elsewhere ». **Annuler ⇒ rien n'est écrit** (la version du sync/import survit, l'éditeur reste ouvert le temps de recopier). **Overwrite ⇒** la version de l'éditeur gagne.✅
### A12 · pas de fausse alerte (non-régression d'A11)
Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :
- **A12a** : `A` ouverte + dirty → Save → modifier encore → Save (le rechargement post-save re-cale la référence)✅
- **A12b** : `A` ouverte + dirty → batch *Add tag* / *Move to folder* sur `A` → Save (l'éditeur a absorbé le changement)✅
- **A12c** : `A` ouverte + dirty → supprimer son dossier → Save (cf. A7)✅
- **A12d** : `A` ouverte + dirty → *Fetch missing* favicons → Save (l'icône est hors payload éditeur)✅
- **A12e** : `A` ouverte + dirty → sync **sans** changement distant sur `A` → Save✅
### A13 · entrée supprimée ailleurs pendant l'édition ✅
- **ACTION** : `A` ouverte + dirty → un sync distant supprime `A` (tombstone) → Save
- **ATTENDU** : pas de plantage. L'entrée n'est plus en mémoire → le contrôle A11 passe la main ; le PUT échoue proprement avec un **toast d'erreur** — surtout pas « Saved ».
- **Recette locale, sans second appareil** : ouvrir `A` et la rendre dirty → dans DB Browser, app lancée, `DELETE FROM vault_entries WHERE id = <id>` puis **Write Changes** (sans ça le WAL ne publie rien) → dans l'app, déclencher un `loadEntries()` qui ne concerne pas `A` (mettre une **autre** entrée à la corbeille puis *Empty trash*) → Save.
- **Bug trouvé par ce test** : le serveur répondait **200 « Updated »** pour un `UPDATE` n'ayant touché **aucune** ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse. `HandleUpdateEntry` teste maintenant `RowsAffected` et renvoie 404.
- **Même classe, non corrigé** : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.
### A13b · récupérer la saisie quand l'entrée a disparu ✅
- **ACTION** : même mise en place qu'A13 → au Save, répondre **« Save as new entry »**.
- **ATTENDU** : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. *Cancel* → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
- **Nouvel uuid, pas une résurrection** : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
- **Piège du déclenchement** : le serveur HTTP répond aussi **404** à toute requête qu'il refuse (verrou par token). La bascule teste donc `status === 404` **et** le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.
---
## B — Fermeture de l'éditeur & message de discard
Le message n'apparaît que si `confirmOnUnsaved` est actif **et** que l'entrée
est dirty. Tester avec `askBeforeDelete` **ON puis OFF** (Settings → Security).
### B1 · ✕ sur une **autre** ligne ✅
- **PRÉ** : `A` ouverte + dirty
- **ACTION** : cliquer ✕ (Move to trash) sur la ligne `B`
- **ATTENDU** : `B` part à la corbeille, l'éditeur de `A` **reste ouvert** avec les modifs. Vrai dans les deux réglages de `askBeforeDelete`.
### B2 · ✕ sur la ligne éditée, `askBeforeDelete` = OFF ✅
- **ACTION** : `A` ouverte + dirty → ✕ sur `A`
- **ATTENDU** : modale « Discard unsaved changes? ». **Annuler ⇒ rien n'est supprimé** et l'éditeur reste ouvert. Confirmer ⇒ `A` à la corbeille + éditeur fermé.
### B3 · ✕ sur la ligne éditée, `askBeforeDelete` = ON ✅
- **ATTENDU** : **une seule** modale (« Move to trash »). Pas de double prompt.
### B4 · batch Delete incluant l'entrée éditée ✅
- **ACTION** : `A` ouverte + dirty → cocher `A` + `B` → *Delete*
- **ATTENDU** : une seule modale (« 2 entries will be moved to trash »), puis l'éditeur se ferme.
### B5 · batch Delete **excluant** l'entrée éditée ✅
- **ATTENDU** : l'éditeur reste ouvert, modifs intactes.
### B6 · corbeille — l'éditeur ne se ferme pas pour rien ✅
**Prémisse corrigée** : on **ne peut pas** ouvrir une entrée de la corbeille dans
l'éditeur. `openTrashActions` (js/app.js) est un corps vide, et le menu kebab de
la corbeille n'a pas d'entrée *Edit* — les actions sont sur la carte. Une entrée
purgée n'est donc jamais celle qui est éditée, sauf par le chemin distant décrit
plus bas.
- **ACTION** : `A` ouverte + dirty (vue normale) → aller dans la corbeille → *Delete forever* sur une autre entrée, puis un batch *Delete forever*, puis *Empty trash*.
- **ATTENDU** : l'éditeur de `A` **reste ouvert** avec ses modifs dans les trois cas — la purge ne concerne jamais l'entrée éditée.
- **Cas limite, seul moyen d'y arriver** : `A` ouverte + dirty → un sync distant met `A` à la corbeille → *Empty trash* local → là `closeEditorForDeleted` doit fermer l'éditeur proprement (même famille qu'A13).
### B7 · boutons de la barre de sélection ✅
- **ACTION** : `A` ouverte + dirty → cocher `B` → cliquer successivement *Move to folder…*, *Add tag*, *Clear*
- **ATTENDU** : **aucun** de ces clics ne ferme l'éditeur ni n'affiche de discard.
### B8 · bouton *Empty trash* (barre de titre) ✅
- **ATTENDU** : idem, pas de fermeture parasite de l'éditeur.
### B9 · chemins de fermeture légitimes (non-régression) ✅
- Esc, ✕ de l'éditeur, clic sur le fond de la grille, ouverture d'une **autre** entrée
- **ATTENDU** : message de discard à chaque fois si dirty ; annuler garde l'éditeur ouvert sur la bonne entrée.
### B10 · verrouillage (comportement assumé) ✅
- **ACTION** : `A` ouverte + dirty → auto-lock ou *Lock vault*
- **ATTENDU** : fermeture **sans** message, modifs perdues. Volontaire : la sécurité prime.
### B11 · Esc au-dessus d'un modal ouvert par-dessus l'éditeur ✅
- **ACTION** : `A` ouverte + dirty → ouvrir un modal → Esc → Esc
- **ATTENDU** : 1ᵉʳ Esc ferme **le modal seul**, l'éditeur reste ouvert ; 2ᵉ Esc traite l'éditeur (discard).
- Modals à passer un par un : **+ New folder** (sidebar), **Customize folder**, *Change master password*, générateur de mot de passe, cheatsheet (`?`), historique de mot de passe, palette (`Ctrl+K`), outil TOTP, quick search (`Ctrl+Shift+Q`), **reauth** (déclenché par un export), **picker d'autofill** (`Ctrl+Shift+L` avec 2 entrées qui matchent).
- **Historique** : `#folderModal` (dynamique) n'était dans aucune liste → Esc fermait l'éditeur derrière. `#reauthModal`, `#quickSearchModal`, `#autofillPickerModal` (statiques) non plus.
### B12 · le modal reste fonctionnel après un Esc ✅
- **ACTION** : ouvrir reauth (export) → Esc → **ré-ouvrir** (relancer l'export). Idem quick search et picker d'autofill.
- **ATTENDU** : le modal se ré-affiche normalement. Un Esc ne doit jamais **supprimer** un modal statique du DOM (sinon la fonctionnalité est morte jusqu'au redémarrage).
### B13 · clic sur un toast / l'overlay d'attente ✅
- **ACTION** : `A` ouverte + dirty → lancer une action longue (export, sync) → cliquer sur le toast ou sur le spinner
- **ATTENDU** : l'éditeur ne se ferme pas, pas de discard.
### B15 · Esc pendant une opération bloquante ✅
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou un export, une rotation de mot de passe maître) → presser `Esc` pendant que l'overlay « Encrypting… » est affiché.
- **ATTENDU** : rien ne se passe. Pas de modale « Discard unsaved changes? ». Elle redevient possible une fois l'overlay parti.
- **Historique** : la modale s'ouvrait bel et bien, mais **sous** l'overlay (`z-index 200`) — visible en flou, à demi cliquable. Deux problèmes en un : l'affichage, et surtout le fait d'arbitrer une fermeture d'éditeur pendant qu'une opération écrit sur la même entrée. Garde `isBusy()` posé dans la chaîne Esc de l'éditeur, avec les autres priorités (palette, Settings, modales).
- **Non-régression** : le clic extérieur ne ferme déjà pas l'éditeur pendant l'attente — `#busyOverlay` est dans la liste blanche, et il couvre l'écran de toute façon.
- **Tester les DEUX déclencheurs** : *Sync now* **et** *Export*. Il y a **deux** gestionnaires `Esc` distincts — celui en phase de capture dédié à l'éditeur, et la chaîne globale « une surface par frappe » (confirm → palette → change-master → cheatsheet → … → éditeur). Le premier correctif n'en couvrait qu'un, et l'export passait par l'autre : le bug semblait persister alors qu'il avait été corrigé sur le chemin du sync.
### B16 · hotkeys globaux pendant une opération bloquante ✅
- **ACTION** : lancer un *Export* (ou *Sync now*) → pendant l'overlay, presser `Ctrl+Shift+A`, puis `Ctrl+Shift+Q`.
- **ATTENDU** : ni l'éditeur ni la quick search ne s'ouvrent ; toast « Busy — try again in a moment ». Tout redevient normal une fois l'overlay parti.
- **Pourquoi les hotkeys échappent aux gardes UI** : ils arrivent de Win32, **hors de la fenêtre**. L'overlay qui couvre l'application n'intercepte rien : le panneau s'ouvrait derrière lui, inaccessible.
- **Où est posé le garde** : dans `openSlideOver` et `openQuickSearchModal`, pas sur chaque déclencheur — c'est le point de passage de *tous* les appelants (hotkey, palette de commandes, bouton *Fix* du vault health). Sûr parce qu'**aucun chemin de sauvegarde n'affiche l'overlay** : rien de légitime n'ouvre l'éditeur pendant une opération bloquante.
- **ACTION 2 — autofill** : pendant l'overlay, `Ctrl+Shift+L`, `Ctrl+Shift+P`, `Ctrl+Shift+T`, avec un titre à **un seul** candidat puis à **plusieurs**.
- **ATTENDU** : rien ne se remplit, aucun picker, même toast. Le garde est dans `autofillHandleRequest`, en amont du choix match-unique / picker, donc il couvre les trois hotkeys et les deux issues. Il appelle `Bridge.cancelAutofill(false)` pour que Delphi ne garde pas la fenêtre cible capturée en attente d'un remplissage qui ne viendra jamais.
### B17 · hotkeys pendant la boîte « Enregistrer sous » de l'export ✅
- **ACTION** : lancer un *Export* → à l'apparition du dialogue natif **Enregistrer sous**, basculer sur une autre application (VS Code) → presser les hotkeys d'autofill → revenir à l'app.
- **ATTENDU** : le dialogue est toujours là, au premier plan de l'application, et répond. L'app n'est cliquable qu'à travers lui — c'est normal, une boîte native modale désactive sa fenêtre propriétaire tant qu'on n'a pas répondu.
- **Historique** : le refus « busy » appelait `cancelAutofill()`, dont le chemin Delphi fait un `SetForegroundWindow` vers la fenêtre capturée. Le dialogue passait **derrière** l'autre application, et l'app paraissait figée : bloquée par une modale devenue introuvable. `refocus=0` distingue désormais **refuser** une demande (ne rien déplacer) de **terminer** une interaction utilisateur (rendre le focus à la cible).
- **Généralisation** : un refus ne doit jamais déplacer une fenêtre. Tout nouveau garde qui annule une action doit passer par ce chemin.
- **Deuxième cause, côté Delphi** : `Ctrl+Shift+Q` et `Ctrl+Shift+A` appellent `RestoreWindowFromTray` **avant** que JS ne puisse refuser — un garde JS arrive trop tard par construction. Restaurer la fenêtre propriétaire d'une modale native laisse le dialogue en rade derrière tout le reste : l'app semble gelée alors qu'elle est simplement *désactivée* par une boîte devenue introuvable (au point qu'il fallait un utilitaire de capture d'écran pour la faire réapparaître).
- **Garde** : `NativeModalActive` teste `IsWindowEnabled` sur notre fenêtre — Windows la désactive tant qu'une modale native lui appartient. Posé en tête des **quatre** points d'entrée hotkey (quick search hotkey + menu tray, new entry, autofill). Il ne se contente pas de refuser : il **ramène le dialogue au premier plan** via `GetLastActivePopup`, ce qui est précisément ce dont l'utilisateur a besoin.
- **ATTENDU après correctif** : pendant l'« Enregistrer sous », n'importe quel hotkey **fait réapparaître le dialogue au premier plan, avec le focus**, et une **notification tray** explique que les raccourcis restent inactifs tant qu'il n'a pas répondu.
- **Piège n°1, localiser le dialogue** : `GetLastActivePopup` renvoie **le formulaire lui-même** — la boîte « Enregistrer sous » n'est pas une popup possédée par notre fenêtre. Elle vit en revanche sur notre thread avec la classe standard `#32770`, donc `EnumThreadWindows` la trouve quel que soit son propriétaire. Mesuré : sans ça le bloc de remontée ne s'exécutait jamais, silencieusement.
- **Piège n°2, le focus** : `SetForegroundWindow` est **refusé** à un processus qui n'est pas déjà devant, et le hotkey vient d'une autre application. Il faut attacher notre file d'entrées au thread de la **fenêtre de premier plan** (VS Code). Réutiliser le helper d'autofill ne marche **pas** : il attache au thread de la *cible*, or le dialogue appartient à notre propre thread — l'attache est un no-op et le dialogue remontait sans jamais recevoir le focus.
- **Vérifié au log** : le `WM_HOTKEY` **est** bien distribué pendant la boucle modale du dialogue, et `IsWindowEnabled` détecte correctement l'état. Ces deux points étaient les hypothèses coûteuses à écarter avant de chercher ailleurs.
- **Piège du message** : une **balloon tray**, pas un toast — notre fenêtre est désactivée et derrière le dialogue, rien de rendu dans la page ne serait visible. Elle suit le réglage « alertes tray » : désactivé, tu auras le focus sans le message.
### B18 · clavier pendant une opération bloquante ✅
- **ACTION** : lancer un *Sync now* (ou un export) → pendant l'overlay, presser `Tab` plusieurs fois puis `Espace` / `Entrée`.
- **ATTENDU** : rien ne se passe. Aucun bouton derrière l'overlay ne prend le focus ni ne s'active. Tout redevient normal ensuite, focus compris.
- **ACTION 2 — les raccourcis** : pendant l'overlay, `Ctrl+A` (tout sélectionner), `Ctrl+K` (palette), `j`/`k` (curseur), **`Suppr`** (suppression par lot).
- **ATTENDU** : aucun effet. Le plus important est `Suppr` : supprimer des entrées pendant qu'un sync ou une rotation écrit sur les mêmes lignes est exactement ce que l'overlay doit empêcher.
- **Historique** : l'overlay ne bloque que la **souris** — il couvre la page. Le clavier passait au travers : `Tab` entrait dans les contrôles derrière et `Espace` les activait. Corrigé avec l'attribut natif `inert` (retire un sous-arbre du focus, du hit-testing et de l'arbre d'accessibilité), posé sur chaque enfant de `<body>` **sauf** l'overlay.
- **`inert` ne couvre pas les raccourcis** : ils sont écoutés sur le **document**, donc ils ne traversent aucun élément et rien dans l'arbre ne peut les arrêter. Il a fallu un garde `isBusy()` explicite sur chacun — même famille que les hotkeys Win32, qui arrivent eux de l'extérieur de la fenêtre.
- **Point de vigilance** : `hideBusy()` retire l'inertie **inconditionnellement**. Une opération qui échouerait sans le rétablir laisserait une application qui semble gelée.
- **`inert` ne suffit pas seul** : il retire les contrôles du focus, mais **pas** le défilement. `Espace`, `Page haut/bas`, `Début/Fin` et les flèches font défiler le document **sans aucun élément focalisé** — la page bougeait encore sous l'overlay. Ces touches sont donc avalées en phase de capture tant que l'opération tourne.
- **Vérifier aussi le défilement** : pendant l'overlay, `Espace` et `Page bas` ne doivent rien bouger ; après, le défilement clavier doit refonctionner.
### B19 · cliquer dans le vide ferme l'éditeur, y compris près des barres ✅
- **ACTION** : `A` ouverte → cliquer dans l'**espace vide** à droite du titre « All items », puis dans l'espace vide de la barre de pagination.
- **ATTENDU** : l'éditeur se ferme (avec la modale de discard s'il est *dirty*), comme n'importe quel clic hors du panneau.
- **ATTENDU (non-régression)** : les **commandes** de ces barres ne ferment toujours pas l'éditeur — boutons de page, sélecteur `25 / page`, tri, barre de sélection, *Empty trash*.
- **Historique** : `.content-header` et `.pagination` étaient en liste blanche **en entier**, pour éviter que la barre de sélection et *Empty trash* ne ferment l'éditeur. Effet de bord : leurs grandes zones vides devenaient inertes, et un clic à côté du titre ne faisait plus rien. La liste blanche ne retient plus que les contrôles.
### B14 · dialogues natifs ouverts par un clic programmatique ✅
- **ACTION** : `A` ouverte + **dirty** → (a) Settings → *Import vault* ; (b) télécharger une pièce jointe (fallback web) ; (c) *Upload icon* ; (d) joindre un fichier.
- **ATTENDU** : **seul** le dialogue natif s'ouvre. Pas de « Discard unsaved changes? » par-dessus, l'éditeur reste ouvert et modifié.
- **Historique** : le `<input type=file>` (et le `<a download>`) est attaché à `<body>` puis `.click()` → le clic synthétique remontait au handler click-outside qui le prenait pour un clic dehors. Gardé par `e.isTrusted`.
---
## F — Round-trip export / import (drapeaux & provenance)
### F1 · favori + épingle survivent au restore ✅
- **F1a — entrée supprimée puis réimportée** ✅ : épingler **et** mettre en favori une entrée → *Export vault* → supprimer l'entrée (corbeille + purge) → *Import vault*.
**ATTENDU** : elle revient **épinglée et favorite** (chemin INSERT / bulk-import).
- **F1b — import en mode écrasement** ✅ : même export, mais **sans** supprimer l'entrée. Retirer l'épingle et le favori localement, réimporter, choisir **Overwrite**.✅
**ATTENDU** : les deux drapeaux du fichier sont **réappliqués**.
- **Historique** : `pinned` n'était dans aucun container ; `favorite` y était écrit mais **jamais relu** (ni parser, ni body, ni INSERT) → les deux perdus au restore. Puis F1a a marché sans F1b : le mode écrasement passe par `PUT`, qui ignore volontairement `favorite`/`pinned` pour qu'un **sync** ne puisse jamais écraser les drapeaux locaux (device-local par design). L'import les applique donc via les endpoints de bascule dédiés, uniquement quand ils diffèrent.
### F2 · même chose depuis une auto-backup ✅
- **ACTION** : *Backup now* → supprimer l'entrée → importer le fichier `vault-autobackup-*.json`.
- **ATTENDU** : identique à F1.
- **La présence du fichier ne prouve RIEN** : si le mot de passe de backup remontait vide de la pref, le conteneur serait produit quand même — chiffré avec une chaîne vide, donc parfaitement valide et impossible à ouvrir avec le vrai mot de passe. Le seul critère est que l'**import le déchiffre** : la modale doit lister le bon nombre d'entrées avec leurs sites et identifiants réels.
- **Pourquoi ce test compte depuis 2026-08-02** : c'est le seul chemin qui lit `autoBackupPwd` depuis la pref DPAPI. Il casserait si `loadAutoBackupConfig(true)` n'était pas passé là où le chiffrement a lieu (cf. E9).
### F3 · CSV étranger → drapeaux à 0 ✅
- **ACTION** : importer un CSV sans colonnes favorite/pinned.
- **ATTENDU** : entrées importées ni épinglées ni favorites (défaut 0), pas d'erreur.
### F9 · la corbeille voyage dans les sauvegardes ✅
- **ACTION** : vault avec par exemple 4 entrées vivantes et 2 en corbeille → *Export vault* → *Clear vault* → réimporter.
- **ATTENDU** : état **identique** — 4 dans la liste, 2 en corbeille. Le compteur *Trash* est juste sans avoir à ouvrir la vue.
- **`deleted_at` est préservé**, pas réinitialisé : sinon chaque entrée restaurée repartirait pour un délai d'auto-purge complet, et une entrée supprimée il y a 29 jours recommencerait à zéro. D'où son ajout à l'écho de `GET /entries`.
- **Deuxième test, celui qui a trouvé le bug** : mettre une entrée à la corbeille → exporter → réimporter **sans rien vider**. L'entrée doit être comptée dans les « déjà présentes » (*Overwrite* / *Skip*), **jamais** ajoutée.
La déduplication de l'import ne construisait sa table que depuis `state.entries` : une entrée dont l'`uuid` n'existait **que** dans la corbeille passait pour nouvelle et créait une **seconde ligne partageant son uuid** — l'identité sur laquelle repose toute la sync. Même angle mort que F8.
- **Ne PAS vider la corbeille pour nettoyer des doublons d'uuid** : *Empty trash* et *Delete forever* écrivent un tombstone portant cet `uuid`, et l'autre appareil supprimerait alors le jumeau vivant. Retirer les lignes en trop directement en base.
- **Le sync, lui, n'emporte pas la corbeille** — voir F8 et `buildSyncSnapshot`.
### F8 · mettre à la corbeille puis synchroniser ne duplique pas ✅
- **ACTION** : synchroniser une première fois (l'instantané distant contient les entrées) → mettre **plusieurs** entrées à la corbeille → **Sync now**.
- **ATTENDU** : les entrées restent en corbeille, et **rien n'est recréé** dans la liste vivante. Le résumé ne doit pas annoncer `pulled N new`.
- **Le bug** : 30 entrées mises à la corbeille puis un sync → 30 **doublons vivants** à côté des originaux en corbeille, soit deux lignes partageant un même `uuid`.
- **Cause** : la corbeille est une suppression **douce**, sans tombstone (seul le hard-delete en écrit un), et les entrées supprimées sont absentes de `state.entries`. Le merge voyait donc leurs uuid comme « absents en local » et les repostait comme des entrées neuves.
- **Correctif** : `applyRemoteSnapshot` charge aussi la corbeille et saute tout uuid qui s'y trouve. La décision locale de mettre à la corbeille l'emporte ; c'est le **vidage** de la corbeille qui écrit le tombstone et propage la suppression.
- **Nettoyage si le bug a déjà frappé** : les doublons vivants sont à supprimer à la main. Vérifier aussi l'autre appareil, où ils ont pu être poussés.
### F4 · le sync ne propage PAS les drapeaux (device-local, décidé 2026-07-11) ✅
- **ACTION** : épingler/favoriser une entrée sur le device A → *Sync now* → *Sync now* sur le device B.
- **ATTENDU** : sur B, l'entrée **n'est pas** épinglée/favorite. Et un sync ne doit jamais **retirer** un drapeau posé localement sur B (le PUT serveur ne touche pas ces colonnes).
### F5 · icône custom : provenance préservée ✅
- **ACTION** : upload d'une icône custom → export → supprimer l'entrée → import → puis **Re-fetch all**.
- **ATTENDU** : l'icône custom revient **et** survit au Re-fetch all (le flag `icon_custom` a fait le round-trip).
### F7 · arbitrage last-write-wins du sync ✅
- **PRÉ** : sync configuré (un serveur WebDAV local suffit : `python -m wsgidav.server.server_cli --host=127.0.0.1 --port=8080 --root=<dir> --auth=anonymous`, URL pointant un **fichier**, ex. `http://127.0.0.1:8080/vault-sync.json`).
- **ACTION** : sync (le distant porte `aa`) → changer le username en `C`, Save → fermer l'app → reculer `updated_at` de l'entrée sous la date du distant, directement en base → fermer la base → relancer → **sync sans ouvrir l'entrée**.
- **ATTENDU** : le username revient à `aa`, toast `1 updated`. L'inverse (`updated_at` local plus récent) garde `C`.
- **Piège n°1 — ne rien toucher entre les deux** : ouvrir l'entrée et sauver quoi que ce soit émet un PUT, et **tout PUT rebumpe `updated_at`**. Le recul manuel est alors annulé juste avant le sync et le local regagne : le test s'invalide lui-même.
- **Piège n°2** : c'est bien `updated_at` qui arbitre ([app.sync.js:423](js/app.sync.js:423)), pas `created_at`.
- **Corollaire** : quand le distant gagne, la branche PUT réécrit **toute** l'entrée. Une modification locale non poussée est donc perdue, pas fusionnée champ par champ — c'est le principe du last-write-wins par entrée.
- **Éditeur ouvert pendant le sync** : il ne se rafraîchit **pas** en direct, délibérément (ça écraserait la saisie en cours). Le garde-fou est au Save — modale « Entry changed elsewhere », cf. A11. Fermer sans sauver puis rouvrir montre la version issue du merge.
### F6 · champ Notes multiligne — la forme survit ✅
- **ACTION** : sur un **login**, *Add note* → saisir plusieurs lignes → Save → **rouvrir** l'entrée.
- **ATTENDU** : toujours multiligne et **masqué** ; l'œil révèle. Le contenu conserve ses retours à la ligne.
- **ACTION 2** : dupliquer l'entrée, puis export → import. **ACTION 3** : *Sync now* (aller-retour vérifié — pousser, supprimer localement, re-synchroniser pour faire revenir l'entrée du distant).
- **Trois choses à vérifier séparément** : le contenu avec ses retours à la ligne, la clé `multiline` (rouvre en zone de texte), la clé `is_secret` (masqué, œil présent). Une perte se produirait à l'**ouverture** sur le device receveur, pas pendant le transfert — la sync porte `custom_fields` comme un blob opaque.
- **ATTENDU** : le champ reste multiligne partout — rien à ajouter dans ces chemins, il voyage dans `custom_fields`.
- **Pourquoi ce test existe** : les champs personnalisés sont **reconstruits clé par clé** à deux endroits, la normalisation à l'ouverture et la reconstruction au Save, et chacun **jette ce qu'il ne nomme pas**. `multiline` est donc porté explicitement dans les deux ; sans ça le champ revient sur une ligne à la première réouverture (le contenu survit, mais illisible). Même piège que `options` pour les combobox.
- **Choix de conception** : pas de colonne `notes_enc` dédiée. Le corps d'une entrée *note* **est** `encrypted_password` ; un vrai champ notes aurait voulu une nouvelle colonne chiffrée et les 7 points de passage de CLAUDE.md. `custom_fields` est déjà porté par la rotation, la duplication, l'export/import et la sync.
---
## C — `withEncryptedMeta` sur tout write `/entries`
Test générique, à rejouer après **toute** modif touchant un body POST/PUT
`/entries` : faire l'action, **relancer l'app** (pour relire depuis SQLite),
vérifier que nom / site / username / tags s'affichent encore.
Chemins à couvrir : Save éditeur (neuf + existant), duplicate, import
(bulk + overwrite), sync (`applyRemoteSnapshot`), `addTagToEntry`,
`batchAddTag`, `moveEntryToFolder`, `moveEntriesToFolder`, `batchMoveToFolder`,
`migrateMetadataAtRest` (au unlock), rotation du master password.
Symptôme du bug : après relance, lignes au nom vide / `(no name)`.
---
## D — Vue corbeille
### D4 · les cibles de dépôt n'acceptent que nos glissers ✅
- **ACTION** : dans la **corbeille** (vue tableau), sélectionner du texte dans une cellule et le glisser sur un dossier, sur *(no folder)*, sur un tag, sur *Trash*.
- **ATTENDU** : **aucune** cible ne s'allume. Les lignes de corbeille ne sont pas déplaçables (`draggable=false`), mais glisser du **texte** reste possible partout dans le navigateur.
- **Historique** : les cibles s'allumaient sur n'importe quel glisser puis lisaient `text/plain` pour y trouver des identifiants — le texte d'une cellule donnait `NaN`, filtré, donc rien ne se passait : une invitation à déposer suivie d'aucune action. Nos glissers d'entrées portent désormais leur propre type `application/x-pm-entries` (comme les dossiers avec `application/x-pm-folder`), et les quatre cibles le vérifient **avant** de s'allumer.
- **Non-régression** : glisser une carte ou une ligne (hors corbeille) sur un dossier, sur *(no folder)*, sur un tag et sur *Trash* fonctionne toujours, sélection multiple comprise.
### D5 · cocher une carte ne bouge ni la grille ni le défilement ✅
- **ACTION** : liste longue → **défiler vers le bas** → cocher une carte (clic sur la case, ou `Ctrl`+clic).
- **ATTENDU** : la position de défilement **ne bouge pas**, et la grille ne se décale pas verticalement. La case se coche, la barre de sélection apparaît.
- **Tester les trois vues** : cartes, **liste** et **tableau**. Le décalage a survécu un premier correctif en vues liste et tableau seulement.
- **Deux causes distinctes, corrigées ensemble** : (1) la barre de sélection dépassait la hauteur réservée par `.content-header` et poussait toute la grille. Fixer la hauteur de la **barre** n'a pas suffi : `min-height` sur l'en-tête n'est qu'un plancher, n'importe quel enfant plus haut fait encore grandir la rangée. L'en-tête a donc une hauteur **fixe** (`62px` = 24 de padding + 38 de barre ; `54px` sur écran étroit), ce qui rend tout débordement purement visuel. (2) `toggleChecked` appelait `renderGrid()`, qui reconstruit **toutes** les cartes — le conteneur de défilement repartait donc à zéro et l'utilisateur était renvoyé en haut de la liste. Le basculement se fait désormais **sur place** sur la seule carte concernée.
- **À vérifier aussi** : le ✓ apparaît / disparaît (il est ajouté au rendu, pas par CSS), l'info-bulle bascule *Select* / *Deselect*, et le compteur de la barre est juste. Idem en vue **tableau** et en vue **liste**.
### D6 · l'espacement de l'en-tête ne change pas au défilement ✅
- **ACTION** : liste longue, dans les **trois** vues → comparer l'écart entre le titre « All items » et la barre de pagination, en haut de liste puis après avoir défilé (en-tête collé).
- **ATTENDU** : écart identique dans les deux cas, et aucune ligne visible **entre** les deux barres. En vue tableau, l'en-tête de colonnes reste garé juste sous la pagination.
- **Historique** : l'écart était une **marge** basse, et `--hdr-h` — qui dit à la pagination où se garer — est mesuré avec `offsetHeight`, **qui ignore les marges**. Une fois collée, la pagination remontait donc de 20 px. Corriger la seule mesure aurait laissé une bande **transparente** traversée par les lignes ; passer la marge en **padding** la rend à la fois mesurable et opaque.
### D1 · menu kebab en table view ✅
- **ACTION** : corbeille + vue table → kebab d'une ligne
- **ATTENDU** : `Restore` / `Copy…` / `Delete forever` **uniquement**. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.
### D2 · vues cartes et liste ✅
- **ATTENDU** : boutons restore + purge (déjà le cas avant), pas de drag possible.
### D3 · clic sur une ligne de corbeille ✅
- **ATTENDU** : ouvre *Trash actions*, **jamais** l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).
---
## E — Auth / recovery / mémoire (série 2026-07-25)
Ces flux touchent la dérivation de clé et l'anti-lockout : tester sur un compte
**argon2id-v2** (défaut courant). Rebuild **Delphi** requis (fixes serveur) +
BuildAssets pour le JS.
### E1 · reauth — mauvais master pw reste inline (les 4 flux) ✅
- **ACTION** : sur chacun de *Set/Change PIN*, *Enable Quick Unlock*,
*Generate recovery code*, *Export* → taper un **mauvais** master pw.
- **ATTENDU** : le modal **reste ouvert** avec « Wrong master password. Attempt
N / 5. » ; au 5ᵉ échec ou 429 → toast lockout + fermeture. **Annuler** à tout
moment = action abandonnée, rien fait.
- **Historique** : PIN / Quick Unlock / recovery fermaient + toast au 1ᵉʳ essai.
### E2 · recovery setup sur compte argon2id-v2 ✅
- **ACTION** : *Generate recovery code* → bon master pw.
- **ATTENDU** : code généré, **pas** de « invalid password ».
- **Historique** : `/recovery-key/setup` rejetait tout compte -v2 / argon2id
(vérif serveur ne connaissait que pbkdf2 / pbkdf2-sha256).
### E3 · recovery generate → annuler n'écrit rien ✅
- **PRÉ** : un code de recovery **existe déjà**.
- **ACTION** : *Regenerate code* → bon master pw → à l'affichage du code,
**Cancel** (ou Esc).
- **ATTENDU** : toast « Cancelled — no recovery code was set » ; l'**ancien**
code fonctionne toujours au redeem. Refaire avec **I saved it** → nouveau
code actif, ancien invalidé.
- **Historique** : le setup était POSTé avant l'affichage → annuler écrasait
quand même l'ancien code (chemin silencieux vers le lockout).
### E4 · recovery redeem sur compte argon2id-v2 ✅
- **ACTION** : générer un code, se déconnecter, *Use a recovery code* →
username + code.
- **ATTENDU** : déverrouillage OK, redirection vers *Change master password*.
Le coffre se déchiffre (clé dérivée avec les **bons** params Argon2 renvoyés
par le serveur).
- **Historique** : la réponse redeem n'echoait pas `argon2` → params par
défaut (correct aujourd'hui, lockout latent si params non-défaut).
### E8 · le mode de déverrouillage survit au redémarrage ✅
- **ACTION** : créer un PIN → *Unlock method* = **PIN** → verrouiller (le PIN est bien demandé) → **fermer et relancer l'app**.
- **ATTENDU** : le PIN est demandé, pas le mot de passe maître.
- **Variantes** : mode `both` → mot de passe **puis** PIN au démarrage à froid ; retour en mode `pw` → mot de passe seul ; 5 PIN faux (le blob DPAPI est effacé) → relancer → retour au mot de passe, sans blocage.
- **Historique** : `unlockMode` n'existait que dans `localStorage` — vidé à **chaque** lancement, le port éphémère changeant l'origine — et dans `settings_json`, illisible avant d'être connecté. Au démarrage à froid l'écran d'auth retombait donc toujours sur `'pw'`, alors que le blob PIN était bien présent. Écrit désormais aussi en pref DPAPI (`Bridge.setPref`) et relu par `showAuth`, comme `rememberedUsername`. **Le réglage est device-local par nature** (le blob PIN est lié à ce Windows) : il reste dans `settings_json` pour l'historique, mais c'est la copie DPAPI qui fait autorité au boot.
- **Auto-correction** : si la pref dit `pin` alors que le blob n'existe plus, `applyAuthScreenMode` force `pw` — pas de verrouillage possible.
### E9 · aucun secret au repos dans la mémoire du renderer ✅
Contrôle par dump mémoire, à refaire après toute modification touchant les prefs
ou le déchiffrement.
- **Mise en scène** : rebuild complet → lancer → **Quick Unlock** → **ne rien ouvrir** (aucune entrée, aucun réglage) → dumper le processus.
- **Quel processus** : le *renderer*, pas le plus gros. Le Gestionnaire des tâches le nomme d'après l'onglet — « **WebView2 : Vault** » ; dans Process Explorer, ajouter la colonne *Command Line* et chercher `--type=renderer`. Le processus GPU est souvent 10× plus volumineux et ne contient **aucun** JavaScript : un scan qui n'y trouve rien ne prouve rien.
- **Témoin de validité** : `soState` doit apparaître (~120 en UTF-16). À 0, ce n'est pas le renderer et le test est nul.
- **ATTENDU** : `this_is_password` (mot de passe d'entrée) à **0** — le déchiffrement est à la demande — et le mot de passe d'auto-backup à **0**. Le **nom** de la pref (`autoBackupPwd`) apparaît, c'est un littéral du code source, pas sa valeur.
- **Historique** : le mot de passe d'auto-backup était présent en clair dans cette exact mise en scène, visible dans `Bridge.onPrefResult("autoBackupPwd","…")` **et** dans les tampons du protocole DevTools — donc deux processus — et il **survivait au verrouillage automatique** qui efface pourtant la clé du coffre. Cause : `loadAutoBackupConfig` / `loadSyncConfig` récupéraient les secrets alors que l'interface n'a besoin que de savoir s'ils existent. Voir `prefs/has`.
- **Limite assumée** : après un backup ou un sync **réellement exécuté**, le secret est de nouveau en mémoire — il faut bien le passer à la fonction de chiffrement. Ce qui est supprimé, c'est sa présence permanente sans qu'aucune opération ne l'ait demandée.
- **Après le test** : supprimer les dumps. Ils contiennent des secrets en clair, sans chiffrement.
- **Validation fonctionnelle obligatoire** — les **quatre** chemins qui lisent encore un secret, à repasser après toute modification de `loadAutoBackupConfig` / `loadSyncConfig` : *Backup now* **puis import du fichier produit** (le fichier seul ne prouve rien, cf. F2) · *Test connection* WebDAV **avec authentification** · *Sync now* (le compteur `updated` prouve que l'instantané distant a été **déchiffré**, pas seulement téléchargé) · le backup planifié au déverrouillage.
- **Monter un WebDAV authentifié pour le test** : `python -m wsgidav.server.server_cli -c <config.json>` avec `simple_dc.user_mapping` et **`accept_basic: true`, `accept_digest: false`** — notre client n'envoie que du Basic. Le partage ne doit plus être marqué `(anonymous)` au démarrage. Un premier 401 suivi d'un 200 authentifié dans le journal est **normal** : WinHTTP n'envoie les identifiants qu'après le défi.
- **Piège de saisie** : les champs identifiant / mot de passe n'enregistrent que sur `change` (perte du focus). Taper puis cliquer aussitôt sur *Test connection* peut lire la préférence avant son écriture — les deux passent par des navigations `cmd://`, qui se coalescent. Faire Tab avant de cliquer. **python -m wsgidav.server.server_cli -c "e:\webdav.json"**
### E5 · la clé du coffre n'est plus en clair dans le web storage ✅
- **ACTION** : coffre déverrouillé → console WebView2 :
`sessionStorage.getItem('cryptoKey')`.
- **ATTENDU** : `null`. (Avant : une string base64 de 44 car = la clé AES.)
- **Vérifié par le code, 2026-08-03** — la console est indisponible en prod (DevTools désactivé), mais le résultat est structurel : plus aucun `persistCryptoKey` / `restoreCryptoKey` (seul subsiste le commentaire qui documente leur retrait), et `sessionStorage` ne porte que `username`, `salt`, `kdfIterations`, `hashAlgo`, `authToken`, `csrfToken`, `argon2Params`. Ni sel ni itérations ne sont des secrets — `/login/challenge` les publie.
- **`sessionStorage.removeItem('cryptoKey')` est volontaire** ([js/app.js](js/app.js)) : il efface le blob laissé par une **ancienne** version de l'app. Ne pas le supprimer en croyant à du code mort.
- **Ne pas confondre avec E9** : celui-ci porte sur le web storage (fichiers sur disque), E9 sur la mémoire du processus. Une clé peut être absente de l'un et présente dans l'autre.
- **Bonus** : dump mémoire du renderer → `this_is_password` peut persister
(strings V8, non corrigeable) ; c'est la **clé** qu'on a retirée du storage.
### E6 · non-régression — les 4 exporteurs de clé marchent toujours ✅
Après le retrait du cache sessionStorage, re-tester que ces flux (qui
`exportKey` la clé) fonctionnent : **login**, **Quick Unlock**, **PIN**,
**recovery redeem**, **change master password**.
### E7 · non-régression — éditeur slideover après suppression du modal mort ✅
Le générateur, les tags et la barre de force étaient partagés. Vérifier dans
l'éditeur slideover : créer + éditer une entrée, ajouter/retirer des tags,
**Generate → Use** insère le mot de passe, la barre de force réagit.
- **Bug trouvé par ce test** : la barre restait figée sur le mot de passe précédent. Affecter `.value` en JavaScript **ne déclenche aucun événement** ; le code appelait `soDirtyCheck()` à la main, mais la barre de force écoute `input` et n'était donc jamais prévenue.
- **Correctif** : émettre `new Event('input')` sur le champ plutôt que d'appeler un gestionnaire nommé. Tous les auditeurs du champ sont couverts, y compris ceux qu'on y branchera plus tard — c'est le même piège chaque fois qu'un bouton remplit un champ à la place de l'utilisateur.
---
## G — Vault health (série 2026-07-25)
### G1 · bouton *Fix* quand le changement d'entrée est annulé ✅
- **ACTION** : `A` ouverte + **dirty** → aller sur *Vault health* → cliquer **Fix** sur une **autre** entrée `B` → **Cancel** au message discard.
- **ATTENDU** : **rien ne bouge**. `A` reste ouverte, son mot de passe reste **masqué**, aucun clignotement du bouton Generate.
- Puis refaire → **Discard** : `B` s'ouvre, c'est **le mot de passe de B** qui est démasqué + sélectionné, et c'est le Generate de `B` qui pulse.
- **Le focus RESTE sur le mot de passe** (~1 s après le clic, vérifier que le curseur n'a pas sauté sur le titre) : `openSlideOver` programme un focus différé de 80 ms sur `#soTitle`, neutralisé ici par `{ noFocus: true }`.
- **Historique** : `openSlideOver` ne renvoyait rien, donc `openEntryForFix` continuait après une annulation et démasquait en clair le mot de passe de l'entrée **restée ouverte**.
### G2 · *Fix* sur une entrée supprimée entre-temps — **sans objet**
- **ACTION prévue** : ouvrir *Vault health* → mettre à la corbeille une entrée listée → revenir sur health sans *Recompute* → **Fix** sur cette entrée.
- **Pourquoi c'est irréalisable** : G3 a rendu ce cas impossible à mettre en scène — le cache est invalidé à **chaque** mutation, donc revenir sur le tableau de bord recalcule toujours et l'entrée supprimée n'y figure plus. Le garde reste en place dans le code (`openEntryForFix` sort si `openSlideOver` renvoie `false`), il n'est simplement plus atteignable par l'interface.
- **Tentative la plus proche, vérifiée** : cocher une entrée dans la liste (la barre de sélection apparaît) → passer en *Vault health* → *Delete* depuis cette barre. Le tableau de bord se **recalcule tout seul** et l'entrée disparaît de la liste : toujours pas de liste périmée.
- **Défaut trouvé au passage** : la sélection **survivait au changement de vue**. Visible en santé / authenticator / journal d'audit, qui n'affichent aucune case à cocher tout en gardant la barre d'actions. **Bien plus grave vers la corbeille** : les identifiants cochés désignent des entrées **vivantes**, alors que la barre de la corbeille propose *Delete forever* — on pouvait donc purger définitivement des entrées qui n'y étaient jamais entrées. La sélection est maintenant vidée dès que la vue change, quelle qu'elle soit.
### G6 · la sélection ne franchit pas les vues ✅
- **ACTION** : cocher deux entrées dans *All items* → passer à la **corbeille**.
- **ATTENDU** : plus rien de coché, barre d'actions disparue. Surtout pas *Delete forever* actif sur une sélection d'entrées vivantes.
- **Variantes** : vers un dossier, un tag, *Notes*, *Recently used*, *Vault health*, *Authenticator*, *Audit log* — même résultat partout. Et au retour dans la vue d'origine, la sélection reste vide (elle n'est pas mémorisée).
### G5 · activer / désactiver HIBP depuis la vue santé ✅
- **ACTION** : dans *Vault health*, activer la vérification HIBP → attendre la fin du scan **sans quitter la vue**.
- **ATTENDU** : la carte *Pwned* et le score se mettent à jour **tout seuls** quand le scan finit.
- **ACTION 2** : désactiver HIBP → la catégorie *Pwned* retombe à 0 immédiatement.
- **ACTION 3** : relancer un scan et **désactiver HIBP en plein milieu**.
- **ATTENDU** : la pastille de progression (en bas, à droite de la barre latérale) **disparaît aussitôt** ; plus aucune requête réseau. Elle continuait à compter après « Breach check disabled », le scan poursuivant un aller-retour réseau par entrée.
- **Historique** : le cache santé était calculé **avant** l'arrivée des résultats et rien ne l'invalidait ensuite. La vue affichait `0 pwned` pendant que les cartes de la liste portaient déjà le drapeau ; il fallait sortir et revenir pour voir `2`. Symétrique à la désactivation, qui vidait `hibpResults` en laissant le tableau de bord compter des brèches disparues. `hibpCheckAllEntries` et le toggle invalident désormais `healthCache`.
### G4 · le score bouge par paliers de 10 — comportement attendu
- **OBSERVÉ** : après correction de deux mots de passe, `30 → 40 → 50`.
- **C'EST NORMAL** : le barème est `-5` par mot de passe **faible**, `-10` par **groupe de réutilisation**, `-2` par entrée **ancienne**, `-15` par entrée **compromise**. Corriger un mot de passe partagé par deux entrées casse le groupe → `+10`.
- **Effet de bord à connaître** : chaque catégorie est **plafonnée** (`-40` faible, `-30` réutilisé, `-20` ancien, `-50` compromis). Au-delà de 8 mots de passe faibles, en corriger un ne change **rien** au score tant qu'on n'est pas repassé sous le plafond. Le plafond existe pour qu'une seule catégorie ne puisse pas écraser les autres, mais il rend les premières corrections invisibles quand le coffre en compte beaucoup.
### G3 · le tableau de bord reflète les mutations ✅
- **ACTION** : ouvrir *Vault health* (noter le score) → revenir à la liste → faire une mutation : **import**, **sync**, **suppression**, **batch delete**, **duplicate**, **restore**, ou **changement de master pw** → rouvrir *Vault health*.
- **ATTENDU** : score et listes **recalculés** (les entrées supprimées ont disparu, les importées apparaissent) **sans** cliquer *Recompute*.
- **Historique** : le cache n'était vidé qu'au lock / au save / via *Recompute* — après un import ou une suppression le dashboard restait figé sur l'état précédent.
---
## H — Hotkey 2FA (`Ctrl+Shift+T`, série 2026-07-25)
### H1 · remplissage simple ✅
- **ACTION** : entrée avec TOTP → ouvrir son site → **cliquer le champ 2FA** → `Ctrl+Shift+T`.
- **ATTENDU** : le code à 6 chiffres est tapé dans le champ focalisé, toast « 2FA code filled: … ».
### H2 · entrée sans TOTP ✅
- **ACTION** : fenêtre dont le titre matche une entrée **sans** secret TOTP → `Ctrl+Shift+T`.
- **ATTENDU** : « No 2FA code stored for "…" » (et **pas** le « no match » générique, ni un remplissage du mot de passe).
### H3 · picker multi-match ✅
- **PRÉ** : 2 entrées matchent le titre, **une seule** a un TOTP.
- **ATTENDU** : le picker s'ouvre uniquement s'il reste ≥ 2 candidats **avec** TOTP ; ici il ne doit pas s'ouvrir (1 seul candidat éligible → remplissage direct). En-tête du picker : « Pick entry (2FA code) — ».
### H4 · combo configurable ✅
- **ACTION** : Settings → Autofill → cliquer le bouton `Ctrl+Shift+T` → capturer un autre combo.
- **ATTENDU** : accepté et actif immédiatement ; **refusé** s'il duplique un des 4 autres hotkeys ; *Reset to defaults* le remet à `Ctrl+Shift+T` ; le réglage survit à un redémarrage (synced via `settings_json`).
### H5 · coffre verrouillé ✅
- **ACTION** : verrouiller → `Ctrl+Shift+T`, puis **`Ctrl+Shift+Q`**, depuis une autre application (Notepad).
- **ATTENDU** : l'app revient au premier plan **et y reste**. Pas de no-op silencieux, et **aucun clignotement** : elle ne doit pas apparaître puis rendre la main à l'application de départ.
- **Où atterrit le curseur** : sur l'**identifiant** s'il est vide, sinon sur le mot de passe — même règle pour les quatre hotkeys. La quick search forçait le mot de passe, ce qui plaçait le curseur dans le second champ lors d'un premier déverrouillage ou sans identifiant mémorisé.
- **Historique** : les deux chemins « coffre verrouillé » annulaient l'autofill avec le refocus **par défaut**, qui rend volontairement le premier plan à la fenêtre cible — juste après que Delphi ait affiché notre fenêtre. Sur `Ctrl+Shift+Q` ça se voyait comme un flash d'une frame avant le retour à Notepad ; sur `Ctrl+Shift+L` le `focusApp()` qui suit reprenait le focus, donc deux déplacements de fenêtre pour finir là où un seul suffit. Les deux passent maintenant `refocus=false` (cf. B17 : **un refus ne doit jamais déplacer une fenêtre**).
### H9 · retour au tray après un pick dans le picker ✅
- **ACTION** : app **au tray** → `Ctrl+Shift+L` depuis une autre app sur un titre à plusieurs candidats → le picker sort la fenêtre du tray → choisir une entrée.
- **ATTENDU** : le remplissage se fait puis l'app **repart au tray**, comme elle est venue.
- **Variantes** : match unique (pas de picker) → reste au tray ; app déjà visible → reste visible ; app **minimisée dans la barre des tâches** → n'est **pas** envoyée au tray (`not Self.Visible` est faux pour une fenêtre iconique) ; picker fermé par Esc sans choisir → l'app **repart au tray** elle aussi (symétrique de quick-search, qui se cache sur Esc) et le hotkey suivant n'hérite pas de l'état capturé.
- **Historique** : seul le chemin quick-search capturait « était cachée » (`LWasHidden`) et le faisait remonter via `hide_after` ; JS n'envoie jamais ce paramètre depuis le picker d'autofill, donc l'app restait sur le bureau. Capturé désormais côté Delphi dans `BridgeAutofillRequest`, au moment du hotkey — après, le picker a déjà détruit l'information.
### H10 · le focus revient à l'application précédente ✅
Cacher une fenêtre ne transfère le premier plan à personne (contrairement à une
minimisation). On restait donc au premier plan **en étant invisible** : le
hotkey suivant capturait **notre propre fenêtre** comme cible et tapait le mot
de passe dedans, et `Ctrl+Shift+A` ouvrait un « New entry » au titre vide (le
garde refusait, à raison, de pré-remplir avec « Password Manager »).
- **ACTION** : depuis une application X → ouvrir l'app (icône du tray, hotkey, ou lancement à froid) → fermer par la croix.
- **ATTENDU** : X reprend le focus. Puis un hotkey d'autofill depuis X doit cibler X, et `Ctrl+Shift+A` pré-remplir son titre.
- **ACTION 2** : enchaîner plusieurs allers-retours en changeant d'application entre deux — c'est **toujours la dernière réellement utilisée** qui récupère le focus.
- **Non-régression** : après un autofill (pick ou Esc), le focus reste sur la fenêtre **remplie** — ces deux chemins passent `AReturnForeground = False`, ayant déjà placé le premier plan eux-mêmes.
- **Historique — quatre tentatives, toutes tranchées par le log** : (1) rendre le focus *après* avoir caché la fenêtre — Windows n'autorise que le propriétaire courant du premier plan à le céder, l'appel était silencieusement refusé ; (2) échantillonner le premier plan au moment du besoin — il vaut alors *notre* fenêtre, la capture se rejetait elle-même et n'apprenait jamais rien (`prev=<none>` à chaque fois) ; (3) capturer au clic sur l'icône du tray — on obtient une fenêtre du **shell** (`NotifyIconOverflowWindow`, « Fenêtre de dépassement de capacité de la barre d'état système ») ; (4) consommer la valeur après usage — le premier masquage marchait, les suivants plus. **Solution** : un hook `EVENT_SYSTEM_FOREGROUND` (`WINEVENT_OUTOFCONTEXT`, aucune injection) note *chaque* changement et garde le dernier qui n'est ni à nous ni au shell ; la restitution se fait **avant** de cacher.
### H13 · pas de `Ctrl+A` après le Tab (formulaires type Reddit) ✅
- **ACTION** : `Ctrl+Shift+L` sur un formulaire où **Tab depuis le username ne mène pas au champ mot de passe** (page de connexion Reddit : le Tab tombe à côté).
- **ATTENDU** : plus de **sélection de toute la page**. Le mot de passe, lui, n'est toujours pas saisi sur ce formulaire — et ce n'est pas corrigeable : **vérifié au Tab manuel, sans autofill**, le Tab depuis le username va sur le **bouton œil** (révéler le mot de passe), que Reddit place avant le champ dans l'ordre de tabulation. On envoie des frappes sans jamais voir où le focus atterrit.
- **Contournement, seul chemin fiable sur ces formulaires** : cliquer le champ mot de passe puis `Ctrl+Shift+P` (password seul, aucun Tab, aucune supposition) — **testé sur Reddit, fonctionne**.
- **Ne pas « corriger » en envoyant deux Tab** : l'ordre de tabulation dépend de chaque site, un second Tab casserait tous les formulaires normaux.
- **Historique** : la séquence envoyait `Ctrl+A + Del` avant **chaque** champ. Après le Tab c'était doublement mauvais : redondant (arriver par Tab sélectionne déjà tout le contenu du champ, cf. K1) et destructeur quand le Tab tombe sur un lien, un bouton ou le document — `Ctrl+A` sélectionne alors la page entière et la frappe se perd. Retiré ; celui d'avant le username reste (l'utilisateur y a placé le curseur par un clic, rien n'est sélectionné).
### H11 · l'application précédente réduite reste réduite ✅
- **ACTION** : depuis X, **réduire X** dans la barre des tâches → ouvrir le coffre (icône du tray) → le fermer par la croix.
- **ATTENDU** : X **reste réduite**. Le focus part où Windows décide ; on ne rouvre pas une fenêtre que l'utilisateur a rangée.
- **Pourquoi c'est fragile** : `ForceForegroundWindow` fait `ShowWindow(SW_RESTORE)` sur une cible iconique — il est écrit pour l'autofill, où sortir la fenêtre cible est justement le but. Réutilisé tel quel pour la restitution du focus, il changeait l'état de la fenêtre d'une autre application. Garde `not IsIconic` posé côté `MinimizeToTray`, pas dans `ForceForegroundWindow` (l'autofill en a toujours besoin).
### H12 · démarrage `-tray` — l'application qui lance garde le focus ✅
- **ACTION** : depuis une console PowerShell, lancer `.\PMServer.exe -tray`.
- **ATTENDU** : le coffre démarre caché **et la console garde son focus** (rectangle actif dans la barre des tâches).
- **ACTION 2** : « Start with Windows » / redémarrage → rien ne passe au premier plan.
- **Historique** : d'abord codé « rien à rendre, on n'a rien pris ». Faux : FMX affiche son proxy de barre des tâches avant qu'on le cache, donc le premier plan **est** pris un instant, et la console lançeuse restait inactive. La valeur utile est `GStartupForeground`, capturée à l'initialisation de l'unité — avant toute fenêtre à nous. Au démarrage de Windows, le premier plan est une fenêtre du shell, que la capture filtre : rien n'est activé, ce qui est le comportement voulu là.
- **Les quatre réglages de `MinimizeToTray`** (`AReturnForeground`) : fermeture par la croix = **rend** ; quick-search copie-puis-cache = **rend** (c'est ce qui permet le Ctrl+V dans l'app d'origine) ; démarrage `-tray` = **rend** ; autofill et annulation du picker = **ne rendent pas**, seuls cas où le focus a déjà été placé volontairement (sur la fenêtre remplie).
### H7 · combo déjà pris par une autre application ✅
- **ACTION** : assigner à un hotkey un combo qu'une autre app tient déjà en global (ex. `Ctrl+Shift+Q` si Quick Assist / un lanceur l'occupe ; sinon, tester en assignant le même combo dans deux apps).
- **ATTENDU** : toast **nommant le combo refusé** — « Already used by another app: 2FA code — pick another combo » — **et** un ⚠ ambre persistant sur cette ligne de Settings, **et** une pastille ambre sur l'entrée *Settings* de la sidebar (seul indice hors du panneau). Le toast d'avertissement reste ~10 s et se ferme au clic. Les **autres** hotkeys restent fonctionnels (enregistrement best-effort, pas de tout-ou-rien).
- **ACTION 2** : fermer l'app concurrente → *Reset to defaults* (ou re-capturer le combo) → le hotkey redevient actif **et le ⚠ disparaît**.
- **Piège de test** : une app qui capture via un *hook* bas niveau (et non `RegisterHotKey`) avale la frappe sans faire échouer notre enregistrement → hotkey inerte **sans** ⚠. Utiliser AutoHotkey (`^+t::MsgBox`) ou Lightshot pour un vrai conflit.
- **Historique** : le message disait « one or both hotkeys » (faux depuis qu'il y en a 3) sans dire lequel ; et un échec sur **Quick search** ou **New entry** n'était écrit que dans le panel debug — invisible en prod, le hotkey semblait juste cassé.
### H8 · capturer un combo déjà pris par une autre app ✅
- **ACTION** : Settings → cliquer le bouton du hotkey 2FA → presser un combo qu'une autre app détient (ex. `Ctrl+Shift+T`).
- **ATTENDU** : **au relâchement** (pas après un délai), toast « No key registered after Ctrl+Shift. If you did press one, another app already owns that combo — pick a different one. » **et** le bouton reprend son libellé précédent.
- **ACTION 2** : presser un combo libre (ex. `Ctrl+Win+T`) → accepté, actif immédiatement, pas de ⚠.
- **ACTION 3** : cliquer le bouton puis ne rien presser pendant 10 s → « Hotkey capture cancelled — no key was pressed. » et le bouton se restaure (sinon la capture avalerait toutes les frappes jusqu'à Échap).
- **ACTION 4** : cliquer le bouton puis presser un combo **déjà assigné à un autre de nos slots** (ex. `Ctrl+Shift+L`) → « That combo is already used by another hotkey ». Doit fonctionner **sans** déclencher l'autofill : nos 5 hotkeys sont libérés pendant la capture.
- **Historique** : un combo tenu par `RegisterHotKey` (le nôtre **ou** celui d'une autre app) n'atteint jamais WebView2 → la capture restait figée sur « Press combo… » sans message, et la vérif de collision interne était du code mort.
### H6 · code en fin de période ✅
- **ACTION** : presser le hotkey juste avant la fin du compte à rebours de 30 s.
- **ATTENDU** : le code tapé est celui de la période **courante** au moment de la frappe (généré à la demande, pas mis en cache).
---
## I — État de la fenêtre (maximisé, série 2026-07-25)
L'app démarre maximisée (`WindowState = wsMaximized` dans la `.fmx`).
### I1 · maximisé survit à Show desktop ✅
- **ACTION** : fenêtre **maximisée** → `Win+D` (ou *Show desktop*) → revenir à l'app (barre des tâches, tray ou Alt+Tab).
- **ATTENDU** : elle revient **maximisée**, **sans passer par une frame en taille normale**.
- **Historique** : FMX oublie l'état maximisé quand Windows minimise la fenêtre de l'extérieur, et aucun de nos chemins de restore ne tourne pour un clic sur la barre des tâches.
- **Historique (flash)** : une fois l'état final correct, il restait un scintillement — le cadre apparaissait en taille normale puis grandissait. **C'est un bug FMX**, reproduit sur une application FMX vide : FMX perd le maximisé quand Windows minimise le formulaire de l'extérieur. Windows choisit alors `rcNormalPosition` comme cible **avant** d'animer, donc aucune correction réactive ne peut arriver à temps — trois ont été essayées et rejetées par la mesure : `ForceQueue` (un tour de boucle trop tard), appel inline dans `MINIMIZEEND` (le hook est délivré après le `WM_SIZE`), correction dans le `WM_SIZE` lui-même (l'état devient parfait, le flash reste — preuve que le problème n'était plus l'état). Écarté aussi : poser `WPF_RESTORETOMAXIMIZED`, que `GetWindowPlacement` rapporte déjà à 2 sur une fenêtre maximisée (no-op).
**Statut : non corrigé, assumé.** Une cinquième piste a été tentée puis **annulée** : garer `rcNormalPosition` et le remplacer par `rcWork` du moniteur pendant que la fenêtre est en bas, pour que « restaurer en normal » vaille déjà plein écran. Ça supprimait bien le flash, mais tout `SetWindowPlacement` sur une fenêtre en cours de minimisation — ou déjà iconique — laissait **un moignon de fenêtre dans un coin de l'écran**, y compris en différant l'écriture jusqu'à `IsIconic`. Un artefact visible permanent est pire que l'animation. Reverté.
**Ce qui reste** : l'état final est correct (la fenêtre revient bien maximisée), seule l'animation de restauration est laide. Ne pas rouvrir sans une vraie raison — la seule route restante est de sous-classer le `WndProc` de la fenêtre FMX pour corriger le rectangle dans `WM_WINDOWPOSCHANGING`, ce qui insère du code dans la boucle de messages d'une fenêtre qu'on ne contrôle pas, avec le tray, l'autofill et le démarrage `-tray` en aval.
### I2 · normal survit aussi ✅
- **ACTION** : dé-maximiser la fenêtre → `Win+D` → revenir.
- **ATTENDU** : elle reste en **taille normale** — le correctif ne doit jamais forcer le maximise.
### I3 · maximiser / dé-maximiser reste enregistré ✅
- **ACTION** : dé-maximiser → `Win+D` → retour (normal) → maximiser → `Win+D` → retour.
- **ATTENDU** : chaque retour respecte le **dernier geste** de l'utilisateur.
### I4 · retour depuis le tray ✅
- **ACTION** : fenêtre maximisée → fermer (part au tray) → clic sur l'icône du tray.
- **ATTENDU** : revient maximisée (chemin distinct : `RestoreFromTray` rejoue son propre placement).
### I5 · le garde de minimisation ne reste pas bloqué (autofill → tray) ✅
Le chemin le plus subtil : `ExecuteAutofill` **minimise réellement** la fenêtre
(`SW_MINIMIZE`) avant de taper, ce qui lève le garde ; si elle est ensuite
**cachée** dans le tray au lieu d'être restaurée, `MINIMIZEEND` peut ne jamais
arriver.
- **ACTION** : depuis le tray, `Ctrl+Shift+Q` → choisir une entrée → laisser le
remplissage se faire (l'app repart au tray) → rouvrir l'app par l'icône du
tray → **dé-maximiser** (ou maximiser) → `Win+D` → revenir.
- **ATTENDU** : le dernier geste est respecté. S'il ne l'est pas, le garde est
resté levé et le suivi de l'état est gelé pour toute la session.
- **Variante** : même chose avec `Ctrl+Shift+L` depuis une autre application.
### I6 · le hook n'affecte pas la corbeille ni le tray (non-régression) ✅
- **ACTION** : minimiser dans le tray (fermeture), restaurer ; `app/focus` via
le picker d'autofill multi-match ; « Lock vault » depuis le menu du tray.
- **ATTENDU** : comportements inchangés. Le hook n'écoute que **notre** process
et `MinimizeToTray` utilise `SW_HIDE`, qui n'émet aucun événement de
minimisation.
### Diagnostic
Avec `config.txt` contenant `debug=true`, `Ctrl+Shift+D` affiche le journal.
Lignes utiles : `[winstate] minimise hook installed=True` au démarrage (un
`False` signifierait que le hook Win32 ne s'installe pas → préservation
inactive) et `[winstate] re-maximising after restore` quand la correction agit.
---
## J — Quick search depuis le tray (série 2026-07-25)
Le flux le plus fragile : la fenêtre revient du tray, un modal s'ouvre, et la
fermeture doit **renvoyer l'app au tray**. Deux `cmd://` s'y enchaînent, ce qui
est précisément le piège de coalescence documenté dans CLAUDE.md.
### J1 · Esc dès l'ouverture, sans cliquer ✅
- **PRÉ** : app **dans le tray**, coffre déverrouillé.
- **ACTION** : `Ctrl+Shift+Q` → **ne rien cliquer** → `Esc` immédiatement.
- **ATTENDU** : le modal se ferme **du premier coup**, et l'app **repart au tray**.
- **Historique** : deux bugs distincts ici. (1) WebView2 n'avait pas le focus
clavier de l'OS après la restauration → aucune frappe n'atteignait la page,
Esc ne faisait rien tant qu'on n'avait pas cliqué dans le champ. (2) En mode
fill, `cancelAutofill` puis `minimizeToTray` = deux `window.location.href`
consécutifs → **coalescés**, la minimisation était perdue.
### J2 · remplissage puis retour au tray ✅ ✅
- **ACTION** : app dans le tray → `Ctrl+Shift+Q` → choisir une entrée (Enter).
- **ATTENDU** : le mot de passe est tapé dans la fenêtre cible **et** l'app
repart au tray — **une seule fois** (c'est Delphi qui minimise ici, via
`hide_after`, pas le JS : vérifier qu'il n'y a pas de double minimisation).
- **Variantes** : `Shift+Enter` (user+pwd), `Ctrl+Enter` (username seul).
### J3 · copie depuis le menu du tray ✅
- **ACTION** : menu du tray → *Quick search…* → choisir une entrée (Enter).
- **ATTENDU** : mot de passe copié, app de retour au tray, et le presse-papiers
**conserve** la valeur (`keepclip=1` — c'est le rôle du paramètre).
### J4 · app déjà visible (non-régression) ✅
- **ACTION** : app **ouverte à l'écran** → `Ctrl+Shift+Q` → `Esc`.
- **ATTENDU** : le modal se ferme et l'app **reste à l'écran** — elle ne doit
**pas** partir au tray (elle n'en venait pas). Idem si elle était seulement
minimisée dans la barre des tâches.
---
## K — Autofill : comportements intentionnels (à ne pas « corriger »)
### K1 · « Clear the field before typing » désactivé → le password est quand même remplacé
- **ACTION** : Settings → Autofill → décocher *Clear the field before typing*.
Puis remplissage complet (`Ctrl+Shift+L`, ou clic-droit dans la quick search)
sur un formulaire dont les deux champs contiennent déjà quelque chose.
- **OBSERVÉ** : le **username** n'est pas effacé (la frappe s'insère), mais
l'ancien **password** disparaît et est remplacé.
- **C'EST NORMAL** — vérifié de bout en bout : le JS envoie `&clear=0`, Delphi
met `FAutofillPendingClear := False`. Notre code n'efface rien. (Depuis le
correctif H13 il n'y a de toute façon **plus qu'un seul** `ClearFieldIfWanted`,
avant le username : celui d'avant le password a été retiré.)
La cause est le **Tab** du pipeline `username → Tab → password` : arriver
dans un champ par Tab **sélectionne tout son contenu** (comportement standard
Windows / navigateur), et la frappe remplace la sélection. Le username, lui,
a été focalisé par un **clic** de l'utilisateur → curseur sans sélection →
insertion.
- **Ce que le réglage fait réellement** : empêcher l'envoi de `Ctrl+A`. Il
existe pour les terminaux / bureaux distants où `Ctrl+A` signifie « début de
ligne » ou déclenche le préfixe tmux, ce qui casserait le remplissage. Il ne
promet pas de préserver le contenu des champs.
- **Ne pas « corriger »** : désélectionner après le Tab ferait **concaténer** le
nouveau mot de passe à l'ancien — jamais souhaitable.
- **Test qui isole la cause** : `Ctrl+Shift+P` (password seul) en cliquant
directement dans le champ → pas de Tab → l'ancienne valeur **reste**.
---
## L — Démarrage avec Windows (`-tray`, série 2026-07-26)
Le flag `-tray` est ce que « Start with Windows » écrit dans `HKCU\…\Run`
(`"<exe>" -tray`). Pour tester sans redémarrer : `PMServer.exe -tray` depuis le
dossier de sortie. **Ne pas** laisser `-tray` dans *Run → Parameters* de l'IDE,
sinon chaque F9 démarre caché.
### L1 · démarrage caché, sans bouton de barre des tâches ✅
- **ACTION** : lancer avec `-tray` (l'app n'étant pas déjà lancée — sinon le
mutex single-instance fait sortir la nouvelle instance immédiatement).
- **ATTENDU** : **aucune fenêtre**, **aucun bouton** dans la barre des tâches,
icône présente dans le tray, pas de balloon.
- **Historique** : le bouton restait 1-2 s. La fenêtre du formulaire n'était en
fait **jamais** visible — c'est le proxy taskbar FMX (`ApplicationHWND`) qui
s'affichait, et **avant** même que `FormCreate` soit atteint, ce qui rendait
inopérant tout correctif posé depuis le formulaire. Le masquer ne suffit pas
non plus : il faut retirer `WS_EX_APPWINDOW` et poser `WS_EX_TOOLWINDOW`,
depuis le `.dpr`, juste après `Application.Initialize`.
### L2 · le bouton revient à la restauration ✅
- **ACTION** : après L1, clic sur l'icône du tray.
- **ATTENDU** : la fenêtre s'ouvre **et** le bouton réapparaît dans la barre
des tâches.
- **Historique** : `WS_EX_TOOLWINDOW` est permanent — sans le rétablissement
(`TrayProxyShowInTaskbar` dans `RestoreWindowFromTray`) l'app tournait
ensuite sans aucune entrée de barre des tâches.
### L3 · lancement normal (non-régression) ✅
- **ACTION** : lancer **sans** `-tray`.
- **ATTENDU** : fenêtre visible et bouton de barre des tâches présents, comme
avant. Le correctif est entièrement gardé par le flag.
---
## Reste à traiter (constaté pendant l'audit, non corrigé)
1. **Attachments hors garde A11.** Le contrôle « changed elsewhere » couvre le
payload de l'entrée, pas ses pièces jointes (table séparée, écrites par
leurs propres endpoints). Un sync qui remplace une pièce jointe pendant
l'édition n'est pas signalé. Faible risque : l'éditeur ne re-ship jamais
les pièces jointes existantes, donc il n'y a rien à écraser.
2. **La corbeille n'est jamais exportée, et rien ne le dit.** `doExport`
parcourt `state.entries` ; la corbeille vit dans `state.trashed`, une liste
séparée — donc absente de l'export manuel, de l'auto-backup **et** du
snapshot de sync. Le choix se défend (la corbeille est une antichambre de
suppression), mais il est **silencieux** : exporter → vider le coffre →
réimporter fait disparaître définitivement les entrées en corbeille, alors
que l'utilisateur croyait faire une sauvegarde complète.
- **Correctif minimal** : une ligne dans le dialogue d'export — « la
corbeille n'est pas incluse ». Une phrase, aucun risque.
- **Correctif complet** : exporter les entrées supprimées avec leur
`deleted_at` et les réimporter en corbeille. Nouveau champ à porter de
bout en bout (parseur, `encryptImportEntry`, INSERT create **et**
bulk-import), plus le comportement à choisir quand l'entrée existe déjà
côté vivant. C'est la classe de modification aux 7 points de passage
décrite dans CLAUDE.md.
- Constaté le 2026-08-03 en testant export → *Clear vault* → import.
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
unit PMA.Biometric;
{
Fine couche au-dessus de FMX.BiometricAuth.
On passe par le service FMX plutot que par BiometricPrompt en JNI direct :
le rappel de BiometricPrompt exige une classe Java implementant une
interface, donc un .jar a compiler et embarquer. FMX fournit deja ce pont.
MODELE RETENU : la cle du Keystore est creee avec une validite APRES
authentification (quelques dizaines de secondes). L'utilisateur s'authentifie,
puis la cle devient utilisable pendant ce delai. C'est plus simple qu'un
CryptoObject lie a chaque operation, et suffisant ici : la fenetre est courte
et l'application est au premier plan pendant ce temps.
L'APPEL DOIT SE FAIRE SUR LE THREAD PRINCIPAL - il affiche un dialogue
systeme. Le travail lourd (dechiffrement) part ensuite dans un thread.
}
interface
uses
System.SysUtils;
type
/// boUnavailable : l'appareil ne PEUT PAS authentifier - aucune empreinte,
/// aucun code. La cle sous authentification est alors definitivement
/// inutilisable, et le cache qu'elle protege aussi.
/// boFailed : la tentative a echoue pour une autre raison, y compris une
/// exception de la couche Java. Ne dit RIEN sur l'appareil, donc ne
/// justifie jamais de detruire quoi que ce soit.
TBioOutcome = (boOK, boUnavailable, boFailed, boCancelled);
/// L'appareil peut-il authentifier (empreinte, visage, ou code de
/// verrouillage) ? False signifie qu'aucune protection n'est configuree.
function BiometricAvailable: Boolean;
/// Pourquoi la derniere tentative a echoue. Vide apres un succes.
///
/// Existe parce qu'un echec avale sinon sa propre cause : un TBioOutcome ne
/// distingue pas « l'appareil n'a aucune protection » de « la couche Java a
/// leve quelque chose », et l'appelant prend des decisions opposees dans les
/// deux cas - dont celle d'effacer le cache.
function LastBiometricError: string;
/// Demande l'authentification puis appelle AThen. Non bloquant : le resultat
/// arrive par le rappel, sur le thread principal.
/// Si l'appareil ne sait pas authentifier, AThen est appele avec boUnavailable
/// SANS refuser l'acces - a l'appelant de decider. Un telephone sans
/// verrouillage ne peut de toute facon pas proteger la cle.
procedure AuthenticateThen(const APrompt: string; const AThen: TProc<TBioOutcome>);
implementation
// La clause uses de l'implementation doit venir AVANT toute declaration :
// placee apres, Delphi ne s'en plaint qu'a la derniere ligne du fichier.
{$IF DEFINED(ANDROID)}
uses
FMX.BiometricAuth, FMX.Platform;
{$ENDIF}
var
FLastError: string = '';
function LastBiometricError: string;
begin
Result := FLastError;
end;
{$IF DEFINED(ANDROID)}
const
// DeviceCredential inclus : un telephone protege par un schema ou un code,
// sans empreinte enrolee, doit rester utilisable. C'est le cas courant.
WANTED_STRENGTHS = [TBiometricStrength.Strong,
TBiometricStrength.DeviceCredential];
// Repli quand la combinaison ci-dessus est refusee. Observe sur un appareil
// avec schema et AUCUNE empreinte : selon les versions, demander
// BIOMETRIC_STRONG en meme temps que DEVICE_CREDENTIAL fait echouer
// l'ensemble au lieu de retenir ce qui existe. On redemande alors juste ce
// dont on a besoin.
CREDENTIAL_ONLY = [TBiometricStrength.DeviceCredential];
var
// Le jeu retenu par la derniere interrogation reussie. AuthenticateThen doit
// presenter EXACTEMENT celui-la : demander autre chose que ce qui a repondu
// « disponible » ramenerait le meme refus.
FStrengths: TBiometricStrengths = WANTED_STRENGTHS;
/// Recupere le service ET pose les forces d'authentification.
///
/// L'ORDRE N'EST PAS COSMETIQUE. Le service Android construit son masque
/// d'authentificateurs a partir de ces forces, et son champ demarre a
/// l'ensemble VIDE - le constructeur ne l'initialise pas. Interroger
/// CanAuthenticate avant de les poser revient donc a demander
/// canAuthenticate(0), qui ne repond jamais « disponible », quel que soit ce
/// que l'appareil sait faire.
function AcquireService(out ASvc: IFMXBiometricAuthService): Boolean;
begin
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXBiometricAuthService, ASvc);
if Result then
ASvc.SetBiometricStrengths(FStrengths);
end;
{$ENDIF}
function BiometricAvailable: Boolean;
{$IF DEFINED(ANDROID)}
var
LSvc: IFMXBiometricAuthService;
begin
// try/except et non un simple test : CanAuthenticate LEVE une
// java.lang.SecurityException si USE_BIOMETRIC manque au manifeste, au lieu
// de repondre False. Sans cette garde, un manifeste incomplet ne se traduit
// pas par « pas de biometrie » mais par une boite d'exception en pleine
// ouverture du coffre.
FLastError := '';
try
FStrengths := WANTED_STRENGTHS;
if not AcquireService(LSvc) then
begin
FLastError := 'biometric service missing from the platform';
Exit(False);
end;
Result := LSvc.CanAuthenticate;
if not Result then
begin
// Second essai sans la biometrie forte : le schema seul suffit a
// deverrouiller la cle, et c'est tout ce qu'on demande.
FStrengths := CREDENTIAL_ONLY;
LSvc.SetBiometricStrengths(FStrengths);
Result := LSvc.CanAuthenticate;
if Result then
FLastError := 'device credential only (no biometric enrolled)'
else
begin
FStrengths := WANTED_STRENGTHS;
FLastError := 'CanAuthenticate: no usable protection';
end;
end;
except
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
Result := False;
end;
end;
end;
{$ELSE}
begin
Result := False;
end;
{$ENDIF}
procedure AuthenticateThen(const APrompt: string; const AThen: TProc<TBioOutcome>);
{$IF DEFINED(ANDROID)}
var
LSvc: IFMXBiometricAuthService;
LProps: TBiometricPromptProperties;
begin
if not Assigned(AThen) then Exit;
// AcquireService pose les forces ; CanAuthenticate n'a de sens qu'apres.
FLastError := '';
if not AcquireService(LSvc) then
begin
FLastError := 'biometric service missing from the platform';
AThen(boFailed); // pas boUnavailable : on ne sait rien de l'appareil
Exit;
end;
try
if not LSvc.CanAuthenticate then
begin
// Reponse NETTE du systeme : l'appareil n'a rien pour authentifier.
FLastError := 'CanAuthenticate: no usable protection';
AThen(boUnavailable);
Exit;
end;
except
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
AThen(boFailed);
Exit;
end;
end;
LProps := Default(TBiometricPromptProperties);
LProps.Title := 'Unlock the vault';
LProps.Description := APrompt;
LProps.CancelButtonText := 'Cancel';
LSvc.SetPromptProperties(LProps);
// Meme raison que plus haut : ce qui remonte de la couche Java doit
// ressortir en TBioOutcome, jamais en exception - l'appelant a un chemin
// pour chaque issue, il n'en a aucun pour une exception.
try
LSvc.Authenticate(
procedure
begin
AThen(boOK);
end,
procedure(const AFailReason: TBiometricFailReason; const AMessage: string)
begin
if AFailReason = TBiometricFailReason.Cancelled then
AThen(boCancelled)
else
AThen(boFailed);
end);
except
on E: Exception do
begin
FLastError := E.ClassName + ': ' + E.Message;
AThen(boFailed);
end;
end;
end;
{$ELSE}
begin
if Assigned(AThen) then AThen(boUnavailable);
end;
{$ENDIF}
end.
+158
View File
@@ -0,0 +1,158 @@
unit PMA.Clipboard;
{
Copie dans le presse-papiers, en marquant le contenu SENSIBLE.
POURQUOI PAS IFMXClipboardService : depuis Android 13, le systeme affiche un
apercu de ce qui vient d'etre copie, en bas de l'ecran. Pour un mot de passe,
c'est exactement ce qu'on cherche a eviter - il s'affiche en clair, hors de
l'application, et reste visible quelques secondes.
Le drapeau qui supprime cet apercu est un extra du ClipDescription, que le
service FMX n'expose pas. D'ou ce passage direct par ClipboardManager.
Cela ne remplace pas l'effacement differe : le drapeau cache l'apercu, il ne
vide pas le presse-papiers.
}
interface
/// Copie ATexte. Retourne False si la plateforme n'a rien fourni - l'appelant
/// peut alors retomber sur le service FMX.
function CopySensitive(const AText: string): Boolean;
/// Contenu courant, ou chaine vide. Sert a ne vider le presse-papiers que
/// s'il contient toujours ce qu'on y a mis.
function CurrentClipboard: string;
/// Vide le presse-papiers.
procedure ClearClipboard;
implementation
{$IF DEFINED(ANDROID)}
uses
System.SysUtils, Androidapi.Helpers, Androidapi.JNI.JavaTypes,
Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Os, Androidapi.JNIBridge;
const
// android.content.ClipDescription.EXTRA_IS_SENSITIVE, ecrit en dur : la
// constante n'existe qu'a partir de l'API 33 dans les en-tetes, alors que la
// valeur est reconnue par le systeme des qu'il sait la lire, et ignoree
// ailleurs. Un extra inconnu ne derange personne.
EXTRA_IS_SENSITIVE = 'android.content.extra.IS_SENSITIVE';
/// Jcontent_ClipboardManager et NON JClipboardManager : ce dernier est
/// l'android.text.ClipboardManager deprecie, qui ne connait que du texte nu et
/// aucun ClipData - donc aucun extra ou poser le drapeau.
function Manager: Jcontent_ClipboardManager;
var
LObj: JObject;
begin
LObj := TAndroidHelper.Context.getSystemService(
TJContext.JavaClass.CLIPBOARD_SERVICE);
if LObj = nil then Exit(nil);
Result := TJcontent_ClipboardManager.Wrap((LObj as ILocalObject).GetObjectID);
end;
function CopySensitive(const AText: string): Boolean;
var
LMgr: Jcontent_ClipboardManager;
LClip: JClipData;
LExtras: JPersistableBundle;
begin
Result := False;
try
LMgr := Manager;
if LMgr = nil then Exit;
// L'etiquette est visible du systeme : elle ne doit rien decrire du
// contenu, seulement son origine.
// Les DEUX arguments sont des CharSequence : passer un JString au premier
// compile ailleurs mais pas ici.
LClip := TJClipData.JavaClass.newPlainText(StrToJCharSequence('vault'),
StrToJCharSequence(AText));
LExtras := TJPersistableBundle.JavaClass.init;
LExtras.putBoolean(StringToJString(EXTRA_IS_SENSITIVE), True);
LClip.getDescription.setExtras(LExtras);
LMgr.setPrimaryClip(LClip);
Result := True;
except
Result := False; // l'appelant retombera sur le service FMX
end;
end;
function CurrentClipboard: string;
var
LMgr: Jcontent_ClipboardManager;
LClip: JClipData;
begin
Result := '';
try
LMgr := Manager;
if (LMgr = nil) or not LMgr.hasPrimaryClip then Exit;
LClip := LMgr.getPrimaryClip;
if (LClip = nil) or (LClip.getItemCount < 1) then Exit;
Result := JCharSequenceToStr(LClip.getItemAt(0).getText);
except
Result := '';
end;
end;
procedure ClearClipboard;
var
LMgr: Jcontent_ClipboardManager;
begin
// clearPrimaryClip QUAND IL EXISTE, et un contenu vide seulement en dessous
// de l'API 28.
//
// Le resultat n'est PAS le meme, contrairement a ce qui etait ecrit ici :
// ecrire un contenu vide reste une COPIE aux yeux du systeme, et Android
// affiche donc sa vignette "copie" - quarante-cinq secondes apres le geste
// de l'utilisateur, sans que rien ne l'explique. Vider n'est pas copier, et
// l'API le distingue depuis 2018.
//
// Elle ne se montre pas a chaque fois : le minuteur ne vide que si le
// presse-papiers contient TOUJOURS notre texte, donc copier autre chose
// entre-temps la supprime. D'ou une apparition sur deux.
try
LMgr := Manager;
if LMgr = nil then Exit;
if TJBuild_VERSION.JavaClass.SDK_INT >= 28 then
LMgr.clearPrimaryClip
else
CopySensitive('');
except
// Un presse-papiers qu'on n'a pas pu vider vaut mieux qu'une exception :
// le contenu partira au prochain copier de l'utilisateur.
end;
end;
{$ELSE}
uses
System.SysUtils, System.Rtti, FMX.Platform;
function CopySensitive(const AText: string): Boolean;
var
LClip: IFMXClipboardService;
begin
// Hors Android il n'y a pas d'apercu systeme a supprimer : le service
// standard suffit.
Result := TPlatformServices.Current.SupportsPlatformService(
IFMXClipboardService, LClip);
if Result then LClip.SetClipboard(AText);
end;
function CurrentClipboard: string;
var
LClip: IFMXClipboardService;
begin
Result := '';
if TPlatformServices.Current.SupportsPlatformService(IFMXClipboardService, LClip) then
Result := LClip.GetClipboard.ToString;
end;
procedure ClearClipboard;
begin
CopySensitive('');
end;
{$ENDIF}
end.
+829
View File
@@ -0,0 +1,829 @@
unit PMA.Crypto;
{
Opens a pm-encrypted-export-v1 container (the vault snapshot) on Android.
Format, produced by js/app.import.js encryptExportPayload():
kdf 'pbkdf2-sha256'
kdf_iterations 600000
kdf_salt base64, 32 bytes
iv base64, 12 bytes
ciphertext base64, AES-GCM output = ciphertext || 16-byte tag
(payload) UTF-8 JSON
TWO DIFFERENT ROUTES, on purpose:
- AES-GCM goes through JNI to javax.crypto. One call, no loop, and the
platform implementation is hardware-accelerated. Java's Cipher expects the
tag appended to the ciphertext, which is exactly what WebCrypto produces -
the buffers are byte-identical, nothing to reshape.
On Windows the same primitive comes from bcrypt.dll (CNG) - no DLL to
ship, no OpenSSL. It exists so the client can be exercised on the desktop:
deploying an APK to see a list redraw is a slow way to build a UI.
- PBKDF2 is implemented HERE rather than via SecretKeyFactory, because
javax.crypto.spec.PBEKeySpec takes a char[] and the provider decides how
those chars become bytes: UTF-8 on some, low-byte on others. For a
non-ASCII sync password the derived key would differ from the browser's,
and the failure is silent - "wrong password" on a correct password. Doing
it here fixes the encoding at UTF-8 and makes the match exact by
construction. The cost is 600k HMAC-SHA256 rounds in Pascal; measure it on
a real device (see DeriveKeyPBKDF2's note) before considering the JNI
route again.
}
interface
uses
System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash,
// System.DateUtils : TTimeZone, pour horodater le conteneur en UTC.
System.DateUtils;
type
EPMACrypto = class(Exception);
/// Derive a key the same way WebCrypto's deriveExportKey does: PBKDF2-SHA256
/// over the UTF-8 bytes of the password. AKeyLenBytes = 32 for AES-256.
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// Same derivation, delegated to the platform - natively implemented, so
/// faster than the Pascal loop. Android: SecretKeyFactory. Windows: CNG's
/// BCryptDeriveKeyPBKDF2, which takes BYTES and so carries none of the doubt
/// below.
///
/// NOT used by DecryptContainer, and deliberately so: PBEKeySpec takes a
/// char[] and the provider decides how those chars become bytes. If Android
/// encodes them as anything other than UTF-8, this returns a DIFFERENT key for
/// a non-ASCII password - silently, reported later as a wrong password.
/// The self-test compares it against the known vector on a real device; adopt
/// it only if they match there. Returns nil on any failure.
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// Why the last DeriveKeyPBKDF2Native returned nil. Empty when it succeeded.
function LastNativeError: string;
/// AES-256-GCM decrypt. ACipherText carries the 16-byte tag at the end, as
/// produced by WebCrypto. Raises EPMACrypto when the tag does not verify -
/// which is what a wrong password looks like.
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
/// Full container -> plaintext JSON payload. Validates the format marker so a
/// truncated download or an HTML error page fails with a clear message rather
/// than as "wrong password".
function DecryptContainer(const AContainerJson, APassword: string): string;
/// N octets d'un generateur CRYPTOGRAPHIQUE - SecureRandom sur Android,
/// BCryptGenRandom sur Windows. JAMAIS Random : un sel ou un IV previsible
/// ruine le chiffrement qui les emploie, et rien dans le resultat ne le
/// montrerait. Leve plutot que de rendre des octets faibles.
function SecureRandomBytes(ACount: Integer): TBytes;
/// AES-256-GCM chiffrement. Le tag de 16 octets est colle A LA FIN du
/// resultat, comme le fait WebCrypto - c'est ce que DecryptContainer et le
/// poste Windows attendent tous les deux.
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
/// Texte JSON -> conteneur complet, dans le format exact que le poste ecrit
/// (`pm-encrypted-export-v1`). Sel et IV tires a chaque appel : deux
/// chiffrements du meme coffre ne se ressemblent pas, et c'est voulu.
function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer = 600000): string;
/// Comme DecryptContainer, mais rend AUSSI la cle derivee, son sel et le
/// nombre d'iterations. Sert au seul cas ou l'on rechiffre juste apres avoir
/// dechiffre : deriver deux fois le meme mot de passe coute deux fois quatre a
/// six secondes sur un telephone, pour un resultat identique.
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
/// Le sel et le nombre d'iterations du conteneur, SANS rien deriver.
///
/// Sert a repondre a une seule question : la cle qu'on tient deja convient-elle
/// a ce conteneur ? Y repondre coute une lecture de JSON, alors que la deriver
/// pour rien coute quatre a six secondes.
function ContainerKdfParams(const AContainerJson: string;
out ASalt: TBytes; out AIterations: Integer): Boolean;
/// Dechiffre avec une cle DEJA derivee - le pendant d'EncryptContainerWithKey.
/// L'APPELANT DOIT AVOIR VERIFIE que le sel et les iterations du conteneur sont
/// ceux de cette cle : sans quoi le dechiffrement echoue sur la marque de GCM,
/// et le message parlera d'un mot de passe faux alors qu'il est bon.
function DecryptContainerWithKey(const AContainerJson: string;
const AKey: TBytes): string;
/// Rechiffre avec une cle DEJA derivee. L'IV, lui, est retire a chaque appel :
/// reutiliser un IV avec la meme cle casse GCM entierement, alors que
/// reutiliser le sel ne fait que redonner la meme cle - ce qui est justement
/// le but.
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
implementation
{$IF DEFINED(ANDROID)}
uses
// Androidapi.Jni : sans elle, TJavaArray.Length reste un appel non inline
// et le compilateur emet un avertissement a chaque usage.
Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
{$ENDIF}
{$IF DEFINED(MSWINDOWS)}
// CNG, declare ici : Delphi ne livre pas d'unite Winapi.Bcrypt, et six
// fonctions ne justifient pas d'en importer une d'ailleurs.
const
BCRYPT_DLL = 'bcrypt.dll';
BCRYPT_ALG_HANDLE_HMAC_FLAG = $00000008;
AUTH_MODE_INFO_VERSION = 1;
type
// Disposition identique au C : champs dans l'ordre, alignement par defaut
// ({$A8}) - les pointeurs et le UInt64 tombent sur 8, comme chez MSVC.
TBCryptAuthCipherModeInfo = record
cbSize: Cardinal;
dwInfoVersion: Cardinal;
pbNonce: PByte;
cbNonce: Cardinal;
pbAuthData: PByte;
cbAuthData: Cardinal;
pbTag: PByte;
cbTag: Cardinal;
pbMacContext: PByte;
cbMacContext: Cardinal;
cbAAD: Cardinal;
cbData: UInt64;
dwFlags: Cardinal;
end;
function BCryptOpenAlgorithmProvider(var phAlgorithm: Pointer;
pszAlgId, pszImplementation: PWideChar; dwFlags: Cardinal): Integer; stdcall;
external BCRYPT_DLL;
function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer;
dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
function BCryptSetProperty(hObject: Pointer; pszProperty: PWideChar;
pbInput: PByte; cbInput, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
function BCryptGenerateSymmetricKey(hAlgorithm: Pointer; var phKey: Pointer;
pbKeyObject: PByte; cbKeyObject: Cardinal; pbSecret: PByte;
cbSecret, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
function BCryptDestroyKey(hKey: Pointer): Integer; stdcall; external BCRYPT_DLL;
function BCryptEncrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal;
pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte;
cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer;
stdcall; external BCRYPT_DLL;
function BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte;
cbBuffer: Cardinal; dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL;
function BCryptDecrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal;
pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte;
cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer;
stdcall; external BCRYPT_DLL;
function BCryptDeriveKeyPBKDF2(hPrf: Pointer; pbPassword: PByte;
cbPassword: Cardinal; pbSalt: PByte; cbSalt: Cardinal; cIterations: UInt64;
pbDerivedKey: PByte; cbDerivedKey, dwFlags: Cardinal): Integer; stdcall;
external BCRYPT_DLL;
{$ENDIF}
var
FLastNativeError: string = '';
function LastNativeError: string;
begin
Result := FLastNativeError;
end;
// ---------------------------------------------------------------------------
// PBKDF2-SHA256
// ---------------------------------------------------------------------------
function XorBytes(const A, B: TBytes): TBytes;
var
I: Integer;
begin
SetLength(Result, Length(A));
for I := 0 to High(A) do
Result[I] := A[I] xor B[I];
end;
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
// Straight RFC 2898. One block is enough for a 32-byte key with SHA-256
// (hLen = 32), so there is no block loop to get wrong.
//
// PERFORMANCE: 600k HMAC rounds. Budget a few seconds on a mid-range phone and
// run it OFF the UI thread. If it proves unacceptable, the answer is to cache
// the derived key in the Android Keystore behind biometrics - NOT to lower the
// iteration count, which is shared with the desktop and defines the format.
var
LPwd, LBlock, LU, LT: TBytes;
I: Integer;
begin
if AKeyLenBytes > 32 then
raise EPMACrypto.Create('KeyLen > one SHA-256 block is not implemented');
LPwd := TEncoding.UTF8.GetBytes(APassword);
// U1 = HMAC(pwd, salt || INT32BE(1))
SetLength(LBlock, Length(ASalt) + 4);
if Length(ASalt) > 0 then
Move(ASalt[0], LBlock[0], Length(ASalt));
LBlock[Length(ASalt)] := 0;
LBlock[Length(ASalt) + 1] := 0;
LBlock[Length(ASalt) + 2] := 0;
LBlock[Length(ASalt) + 3] := 1;
LU := THashSHA2.GetHMACAsBytes(LBlock, LPwd, THashSHA2.TSHA2Version.SHA256);
LT := Copy(LU, 0, Length(LU));
for I := 2 to AIterations do
begin
LU := THashSHA2.GetHMACAsBytes(LU, LPwd, THashSHA2.TSHA2Version.SHA256);
LT := XorBytes(LT, LU);
end;
Result := Copy(LT, 0, AKeyLenBytes);
end;
// ---------------------------------------------------------------------------
// AES-GCM through javax.crypto
// ---------------------------------------------------------------------------
{$IF DEFINED(ANDROID)}
type
// Minimal JNI imports. Delphi ships no headers for javax.crypto, and the
// bridge builds its method signatures from these declarations - hence the
// real Java types (Key, AlgorithmParameterSpec) rather than JObject, which
// would generate a signature no method matches.
JKey = interface;
JAlgorithmParameterSpec = interface;
JSecretKeySpec = interface;
JGCMParameterSpec = interface;
JCipher = interface;
JKeyClass = interface(IJavaClass)
['{6E0F5B9A-1C2D-4E3F-8A7B-9C0D1E2F3A4B}']
end;
[JavaSignature('java/security/Key')]
JKey = interface(IJavaInstance)
['{7F1A6CAB-2D3E-4F50-9B8C-AD1E2F3A4B5C}']
end;
TJKey = class(TJavaGenericImport<JKeyClass, JKey>) end;
JAlgorithmParameterSpecClass = interface(IJavaClass)
['{8A2B7DBC-3E4F-5061-AC9D-BE2F3A4B5C6D}']
end;
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
JAlgorithmParameterSpec = interface(IJavaInstance)
['{9B3C8ECD-4F50-6172-BDAE-CF3A4B5C6D7E}']
end;
TJAlgorithmParameterSpec = class(TJavaGenericImport<JAlgorithmParameterSpecClass, JAlgorithmParameterSpec>) end;
JSecretKeySpecClass = interface(JKeyClass)
['{AC4D9FDE-5061-7283-CEBF-DA4B5C6D7E8F}']
function init(key: TJavaArray<Byte>; algorithm: JString): JSecretKeySpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/SecretKeySpec')]
JSecretKeySpec = interface(JKey)
['{BD5EA0EF-6172-8394-DFCA-EB5C6D7E8F90}']
end;
TJSecretKeySpec = class(TJavaGenericImport<JSecretKeySpecClass, JSecretKeySpec>) end;
JGCMParameterSpecClass = interface(JAlgorithmParameterSpecClass)
['{CE6FB1F0-7283-94A5-E0DB-FC6D7E8F9001}']
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
JGCMParameterSpec = interface(JAlgorithmParameterSpec)
['{DF70C201-8394-A5B6-F1EC-0D7E8F900112}']
end;
TJGCMParameterSpec = class(TJavaGenericImport<JGCMParameterSpecClass, JGCMParameterSpec>) end;
JKeySpec = interface;
JPBEKeySpec = interface;
JSecretKey = interface;
JSecretKeyFactory = interface;
// generateSecret takes a KeySpec, not an Object. Declaring the real
// interface is what makes the bridge emit
// (Ljava/security/spec/KeySpec;)Ljavax/crypto/SecretKey; - with JObject it
// emitted a signature no method matches, and the call failed at runtime.
JKeySpecClass = interface(IJavaClass)
['{0A1B2C3D-4E5F-6071-8293-A4B5C6D7E8F9}']
end;
[JavaSignature('java/security/spec/KeySpec')]
JKeySpec = interface(IJavaInstance)
['{1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901}']
end;
TJKeySpec = class(TJavaGenericImport<JKeySpecClass, JKeySpec>) end;
JPBEKeySpecClass = interface(JKeySpecClass)
['{1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F801}']
function init(password: TJavaArray<Char>; salt: TJavaArray<Byte>;
iterationCount: Integer; keyLength: Integer): JPBEKeySpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/PBEKeySpec')]
JPBEKeySpec = interface(JKeySpec)
['{2B3C4D5E-6F70-8192-A3B4-C5D6E7F80112}']
end;
TJPBEKeySpec = class(TJavaGenericImport<JPBEKeySpecClass, JPBEKeySpec>) end;
JSecretKeyClass = interface(JKeyClass)
['{3C4D5E6F-7081-92A3-B4C5-D6E7F8011223}']
end;
[JavaSignature('javax/crypto/SecretKey')]
JSecretKey = interface(JKey)
['{4D5E6F70-8192-A3B4-C5D6-E7F801122334}']
function getEncoded: TJavaArray<Byte>; cdecl;
end;
TJSecretKey = class(TJavaGenericImport<JSecretKeyClass, JSecretKey>) end;
JSecretKeyFactoryClass = interface(JObjectClass)
['{5E6F7081-92A3-B4C5-D6E7-F80112233445}']
function getInstance(algorithm: JString): JSecretKeyFactory; cdecl;
end;
[JavaSignature('javax/crypto/SecretKeyFactory')]
JSecretKeyFactory = interface(JObject)
['{6F708192-A3B4-C5D6-E7F8-011223344556}']
function generateSecret(keySpec: JKeySpec): JSecretKey; cdecl;
end;
TJSecretKeyFactory = class(TJavaGenericImport<JSecretKeyFactoryClass, JSecretKeyFactory>) end;
JCipherClass = interface(JObjectClass)
['{E081D312-94A5-B6C7-02FD-1E8F90011223}']
function _GetDECRYPT_MODE: Integer; cdecl;
function _GetENCRYPT_MODE: Integer; cdecl;
function getInstance(transformation: JString): JCipher; cdecl;
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
end;
[JavaSignature('javax/crypto/Cipher')]
JCipher = interface(JObject)
['{F192E423-A5B6-C7D8-130E-2F9001122334}']
procedure init(opmode: Integer; key: JKey; params: JAlgorithmParameterSpec); cdecl;
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
end;
TJCipher = class(TJavaGenericImport<JCipherClass, JCipher>) end;
// Declare ICI plutot qu'en important Androidapi.JNI.Java.Security : cette
// unite redeclarerait JKey et consorts, et Delphi resout par le dernier uses
// - de quoi relier silencieusement le code existant a d'autres interfaces.
JSecureRandom = interface;
JSecureRandomClass = interface(JObjectClass)
['{9C1D2E3F-4A5B-6C7D-8E9F-0A1B2C3D4E5F}']
function init: JSecureRandom; cdecl;
end;
[JavaSignature('java/security/SecureRandom')]
JSecureRandom = interface(JObject)
['{AD2E3F40-5B6C-7D8E-9F0A-1B2C3D4E5F60}']
procedure nextBytes(bytes: TJavaArray<Byte>); cdecl;
end;
TJSecureRandom = class(TJavaGenericImport<JSecureRandomClass, JSecureRandom>) end;
function ToJavaBytes(const B: TBytes): TJavaArray<Byte>;
var
I: Integer;
begin
Result := TJavaArray<Byte>.Create(Length(B));
for I := 0 to High(B) do
Result.Items[I] := B[I];
end;
function FromJavaBytes(const A: TJavaArray<Byte>): TBytes;
var
I: Integer;
begin
SetLength(Result, A.Length);
for I := 0 to A.Length - 1 do
Result[I] := A.Items[I];
end;
{$ENDIF}
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
{$IF DEFINED(ANDROID)}
var
LChars: TJavaArray<Char>;
LSpec: JPBEKeySpec;
LFactory: JSecretKeyFactory;
I: Integer;
begin
Result := nil;
try
// The password goes in as chars - the whole point of the doubt. Delphi
// strings are UTF-16, so this hands the provider the code units and lets
// it apply whatever encoding it wants. That is exactly what the test
// measures against the vector.
LChars := TJavaArray<Char>.Create(Length(APassword));
for I := 1 to Length(APassword) do
LChars.Items[I - 1] := APassword[I];
LSpec := TJPBEKeySpec.JavaClass.init(LChars, ToJavaBytes(ASalt),
AIterations, AKeyLenBytes * 8); // keyLength is in BITS
LFactory := TJSecretKeyFactory.JavaClass.getInstance(
StringToJString('PBKDF2WithHmacSHA256'));
Result := FromJavaBytes(LFactory.generateSecret(LSpec).getEncoded);
except
// Keep the reason: "unavailable" hid a signature mismatch of mine for a
// whole test round. A message here says whether Android refused, or
// whether the JNI declaration is wrong again.
on E: Exception do
begin
FLastNativeError := E.ClassName + ': ' + E.Message;
Result := nil;
end;
end;
end;
{$ELSEIF DEFINED(MSWINDOWS)}
var
LPrf: Pointer;
LPwd: TBytes;
begin
Result := nil;
LPwd := TEncoding.UTF8.GetBytes(APassword);
LPrf := nil;
// HMAC flag : sans lui le handle sert au hachage simple, et la derivation
// echoue au lieu de silencieusement produire autre chose.
if BCryptOpenAlgorithmProvider(LPrf, 'SHA256', nil,
BCRYPT_ALG_HANDLE_HMAC_FLAG) <> 0 then
begin
FLastNativeError := 'BCryptOpenAlgorithmProvider(SHA256) failed';
Exit;
end;
try
SetLength(Result, AKeyLenBytes);
// Le mot de passe part en OCTETS UTF-8 : contrairement au char[] de Java,
// aucun choix d'encodage n'est laisse au fournisseur.
if BCryptDeriveKeyPBKDF2(LPrf, PByte(LPwd), Length(LPwd),
PByte(ASalt), Length(ASalt), AIterations,
PByte(Result), AKeyLenBytes, 0) <> 0 then
begin
FLastNativeError := 'BCryptDeriveKeyPBKDF2 failed';
Result := nil;
end;
finally
BCryptCloseAlgorithmProvider(LPrf, 0);
end;
end;
{$ELSE}
begin
Result := nil; // no platform provider here
end;
{$ENDIF}
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
{$IF DEFINED(ANDROID)}
var
LCipher: JCipher;
LKeySpec: JSecretKeySpec;
LGcm: JGCMParameterSpec;
begin
try
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
StringToJString('AES'));
// 128 = tag length in BITS. WebCrypto's default is the same 16 bytes; a
// mismatch here fails authentication on otherwise valid data.
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipher.JavaClass.DECRYPT_MODE, LKeySpec, LGcm);
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText)));
except
// AEADBadTagException surfaces here: wrong password, or altered bytes.
// Both are indistinguishable by design - that is what authentication means.
on E: Exception do
// Message en pur ASCII : un tiret cadratin dans une source lue en ANSI
// par Delphi ressort en mojibake sur l'appareil.
raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file');
end;
end;
{$ELSEIF DEFINED(MSWINDOWS)}
var
LAlg, LKey: Pointer;
LInfo: TBCryptAuthCipherModeInfo;
LTag, LCt: TBytes;
LOutLen: Cardinal;
LMode: string;
begin
// GCM porte son tag a la fin, colle par WebCrypto. CNG le veut a part, dans
// la structure - c'est la seule difference avec le chemin Java, qui l'accepte
// concatene.
if Length(ACipherText) < 16 then
raise EPMACrypto.Create('Ciphertext shorter than its GCM tag');
LAlg := nil;
LKey := nil;
if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then
raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed');
try
LMode := 'ChainingModeGCM';
// Longueur AVEC le zero terminal : CNG compare la chaine complete.
if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)),
(Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then
raise EPMACrypto.Create('BCryptSetProperty(GCM) failed');
if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0,
PByte(AKey), Length(AKey), 0) <> 0 then
raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed');
LTag := Copy(ACipherText, Length(ACipherText) - 16, 16);
LCt := Copy(ACipherText, 0, Length(ACipherText) - 16);
FillChar(LInfo, SizeOf(LInfo), 0);
LInfo.cbSize := SizeOf(LInfo);
LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION;
LInfo.pbNonce := PByte(AIv);
LInfo.cbNonce := Length(AIv);
LInfo.pbTag := PByte(LTag);
LInfo.cbTag := Length(LTag);
SetLength(Result, Length(LCt));
LOutLen := 0;
// pbIV a nil : en mode authentifie le nonce passe par la structure, et
// remplir les deux fait echouer l'appel.
if BCryptDecrypt(LKey, PByte(LCt), Length(LCt), @LInfo, nil, 0,
PByte(Result), Length(Result), LOutLen, 0) <> 0 then
// STATUS_AUTH_TAG_MISMATCH arrive ici : mot de passe faux, ou octets
// alteres. Indiscernables par construction - c'est ce qu'authentifier
// veut dire. Meme message que la branche Android.
raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file');
SetLength(Result, LOutLen);
finally
if LKey <> nil then BCryptDestroyKey(LKey);
BCryptCloseAlgorithmProvider(LAlg, 0);
end;
end;
{$ELSE}
begin
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
end;
{$ENDIF}
// ---------------------------------------------------------------------------
// Alea et chiffrement
// ---------------------------------------------------------------------------
function SecureRandomBytes(ACount: Integer): TBytes;
{$IF DEFINED(ANDROID)}
var
LRnd: JSecureRandom;
LArr: TJavaArray<Byte>;
begin
if ACount <= 0 then Exit(nil);
// Le constructeur sans argument s'ensemence tout seul depuis le systeme ;
// fournir une graine soi-meme ne ferait que l'affaiblir.
LRnd := TJSecureRandom.JavaClass.init;
LArr := TJavaArray<Byte>.Create(ACount);
try
LRnd.nextBytes(LArr);
Result := FromJavaBytes(LArr);
finally
LArr.Free;
end;
if Length(Result) <> ACount then
raise EPMACrypto.Create('SecureRandom returned the wrong length');
end;
{$ELSEIF DEFINED(MSWINDOWS)}
const
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
begin
if ACount <= 0 then Exit(nil);
SetLength(Result, ACount);
// Handle nul + drapeau systeme : on prend le generateur par defaut de
// Windows sans avoir a ouvrir puis fermer un fournisseur.
if BCryptGenRandom(nil, PByte(Result), ACount,
BCRYPT_USE_SYSTEM_PREFERRED_RNG) <> 0 then
raise EPMACrypto.Create('BCryptGenRandom failed');
end;
{$ELSE}
begin
raise EPMACrypto.Create('No secure random source on this platform');
end;
{$ENDIF}
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
{$IF DEFINED(ANDROID)}
var
LCipher: JCipher;
LKeySpec: JSecretKeySpec;
LGcm: JGCMParameterSpec;
begin
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
StringToJString('AES'));
// 128 bits de tag, comme au dechiffrement et comme WebCrypto.
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipher.JavaClass.ENCRYPT_MODE, LKeySpec, LGcm);
// Java rend deja le tag colle a la fin - meme disposition que WebCrypto.
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(APlainText)));
end;
{$ELSEIF DEFINED(MSWINDOWS)}
var
LAlg, LKey: Pointer;
LInfo: TBCryptAuthCipherModeInfo;
LTag, LCt: TBytes;
LOutLen: Cardinal;
LMode: string;
begin
LAlg := nil;
LKey := nil;
if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then
raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed');
try
LMode := 'ChainingModeGCM';
if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)),
(Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then
raise EPMACrypto.Create('BCryptSetProperty(GCM) failed');
if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0,
PByte(AKey), Length(AKey), 0) <> 0 then
raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed');
// CNG veut le tag DANS la structure, pas dans le flux : on l'y recueille,
// puis on le colle a la fin pour rendre la meme forme que WebCrypto.
SetLength(LTag, 16);
SetLength(LCt, Length(APlainText));
FillChar(LInfo, SizeOf(LInfo), 0);
LInfo.cbSize := SizeOf(LInfo);
LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION;
LInfo.pbNonce := PByte(AIv);
LInfo.cbNonce := Length(AIv);
LInfo.pbTag := PByte(LTag);
LInfo.cbTag := Length(LTag);
LOutLen := 0;
if BCryptEncrypt(LKey, PByte(APlainText), Length(APlainText), @LInfo,
nil, 0, PByte(LCt), Length(LCt), LOutLen, 0) <> 0 then
raise EPMACrypto.Create('BCryptEncrypt failed');
SetLength(LCt, LOutLen);
Result := Concat(LCt, LTag);
finally
if LKey <> nil then BCryptDestroyKey(LKey);
BCryptCloseAlgorithmProvider(LAlg, 0);
end;
end;
{$ELSE}
begin
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
end;
{$ENDIF}
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
/// Base64 SANS retour a la ligne. TNetEncoding.Base64 coupe a 76 caracteres,
/// comme le courrier electronique ; WebCrypto n'en met aucun. Les decodeurs
/// tolerants ignorent ces retours - `atob` retire les blancs, Node aussi - mais
/// produire exactement la meme forme que l'autre implementation retire une
/// dependance a leur tolerance.
function B64NoWrap(const ABytes: TBytes): string;
var
LEnc: TBase64Encoding;
begin
LEnc := TBase64Encoding.Create(0); // 0 = jamais de coupure
try
Result := LEnc.EncodeBytesToString(ABytes);
finally
LEnc.Free;
end;
end;
function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer): string;
var
LSalt, LKey: TBytes;
begin
if APassword = '' then
raise EPMACrypto.Create('Refusing to encrypt with an empty password');
LSalt := SecureRandomBytes(32);
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32);
if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32);
Result := EncryptContainerWithKey(APlainJson, LKey, LSalt, AIterations);
end;
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
var
LObj: TJSONObject;
LIv, LCt: TBytes;
begin
if (Length(AKey) <> 32) or (Length(ASalt) = 0) then
raise EPMACrypto.Create('Cle ou sel absent pour le rechiffrement');
// L'IV EST TIRE A CHAQUE APPEL, le sel non. Reutiliser un IV avec la meme
// cle casse GCM entierement ; reutiliser le sel ne fait que redonner la meme
// cle, ce qui est justement l'economie recherchee - le sel ralentit une
// attaque sur le mot de passe, il n'a pas a differer d'une version a l'autre.
LIv := SecureRandomBytes(12);
LCt := AesGcmEncrypt(AKey, LIv, TEncoding.UTF8.GetBytes(APlainJson));
LObj := TJSONObject.Create;
try
// Memes cles, memes valeurs que encryptExportPayload cote poste : c'est ce
// conteneur-la que les deux clients savent relire.
LObj.AddPair('format', 'pm-encrypted-export-v1');
LObj.AddPair('kdf', 'pbkdf2-sha256');
LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
LObj.AddPair('kdf_salt', B64NoWrap(ASalt));
LObj.AddPair('iv', B64NoWrap(LIv));
LObj.AddPair('ciphertext', B64NoWrap(LCt));
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
TTimeZone.Local.ToUniversalTime(Now)));
Result := LObj.ToJSON;
finally
LObj.Free;
end;
end;
function DecryptContainer(const AContainerJson, APassword: string): string;
var
LKey, LSalt: TBytes;
LIter: Integer;
begin
Result := DecryptContainerEx(AContainerJson, APassword, LKey, LSalt, LIter);
end;
/// L'enveloppe du conteneur, lue UNE SEULE FOIS pour les trois entrees
/// publiques. Elle a ete recopiee tant qu'il n'y en avait qu'une ; a trois, une
/// divergence entre les copies se paierait par un dechiffrement qui accuse le
/// mot de passe.
procedure ReadContainer(const AContainerJson: string;
out ASalt, AIv, ACt: TBytes; out AIterations: Integer);
var
LObj: TJSONObject;
LFormat, LKdf: string;
begin
LObj := TJSONObject.ParseJSONValue(AContainerJson) as TJSONObject;
if LObj = nil then
raise EPMACrypto.Create('Not a JSON container (truncated download?)');
try
// Checked before anything else: an HTML error page or a half-written file
// would otherwise reach the cipher and be reported as a wrong password.
LFormat := LObj.GetValue<string>('format', '');
if LFormat <> 'pm-encrypted-export-v1' then
raise EPMACrypto.CreateFmt('Unknown container format: "%s"', [LFormat]);
LKdf := LObj.GetValue<string>('kdf', '');
if LKdf <> 'pbkdf2-sha256' then
raise EPMACrypto.CreateFmt('Unsupported KDF: "%s"', [LKdf]);
AIterations := LObj.GetValue<Integer>('kdf_iterations', 600000);
ASalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('kdf_salt', ''));
AIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('iv', ''));
ACt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('ciphertext', ''));
if (Length(ASalt) = 0) or (Length(AIv) = 0) or (Length(ACt) = 0) then
raise EPMACrypto.Create('Container is missing salt, iv or ciphertext');
finally
LObj.Free;
end;
end;
function ContainerKdfParams(const AContainerJson: string;
out ASalt: TBytes; out AIterations: Integer): Boolean;
var
LIv, LCt: TBytes;
begin
Result := True;
try
ReadContainer(AContainerJson, ASalt, LIv, LCt, AIterations);
except
// Une enveloppe illisible n'est pas une erreur ICI : l'appelant va tenter
// le dechiffrement, qui dira ce qui ne va pas avec son propre message.
Result := False;
ASalt := nil;
AIterations := 0;
end;
end;
function DecryptContainerWithKey(const AContainerJson: string;
const AKey: TBytes): string;
var
LSalt, LIv, LCt: TBytes;
LIterations: Integer;
begin
ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations);
Result := TEncoding.UTF8.GetString(AesGcmDecrypt(AKey, LIv, LCt));
end;
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
var
LIterations: Integer;
LSalt, LIv, LCt, LKey: TBytes;
begin
ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations);
// Android's provider first - measured identical to WebCrypto on a
// non-ASCII password (so it does encode as UTF-8), and it is platform
// code rather than ours. Barely faster though: 3869 ms against 4139 ms,
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
// fallback, and is the only one outside Android.
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32);
if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
Result := TEncoding.UTF8.GetString(AesGcmDecrypt(LKey, LIv, LCt));
// Rendus a l'appelant : ils lui evitent une seconde derivation s'il
// rechiffre juste apres, ou s'il rouvre le meme conteneur plus tard.
AKey := LKey;
ASalt := LSalt;
AIterations := LIterations;
end;
end.
+168
View File
@@ -0,0 +1,168 @@
unit PMA.Favicon;
{
L'icone d'un site, par le relais de DuckDuckGo.
MEME SOURCE QUE LE POSTE, et pour la meme raison. Aller chercher
`https://<site>/favicon.ico` en direct ferait resoudre un DNS - et ouvrir une
connexion - vers CHAQUE domaine range dans le coffre. La liste des sites ou
quelqu'un a un compte est precisement ce que ce coffre protege : la donner au
reseau pour un dessin de seize pixels serait un mauvais marche.
Le relais, lui, ne voit qu'une chose : que quelqu'un s'interesse a ce domaine.
C'est deja quelque chose, d'ou le caractere facultatif de l'appel - il ne part
que lorsqu'on le demande, entree par entree.
DEUX ESSAIS : l'hote complet, puis le domaine de second niveau. `chat.qwen.ai`
n'a pas d'icone chez le relais, `qwen.ai` en a une.
}
interface
uses
System.SysUtils;
/// Rend une URI `data:` prete a etre rangee dans `icon_b64`, ou '' si rien
/// n'a ete trouve - AError dit alors pourquoi.
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
/// L'hote d'une adresse, sans schema ni chemin. Public parce que c'est aussi
/// ce qu'il faut pour decider s'il y a quelque chose a chercher.
function HostOf(const ASite: string): string;
implementation
uses
System.Classes, System.NetEncoding, System.Net.HttpClient,
System.Net.URLClient;
const
// 64 Ko : une icone de site en fait quelques-uns. Au-dela, ce n'est pas une
// icone, et l'accepter ferait gonfler l'instantane que chaque enregistrement
// republie en entier.
MAX_BYTES = 64 * 1024;
TIMEOUT_MS = 5000;
function HostOf(const ASite: string): string;
var
I: Integer;
begin
Result := Trim(ASite);
if Result = '' then Exit;
I := Pos('://', Result);
if I > 0 then Result := Copy(Result, I + 3, MaxInt);
// Chemin, requete, ancre, et les identifiants d'une URL a la user@host.
I := Pos('/', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := Pos('?', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := Pos('#', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
I := LastDelimiter('@', Result);
if I > 0 then Result := Copy(Result, I + 1, MaxInt);
I := Pos(':', Result); if I > 0 then Result := Copy(Result, 1, I - 1);
Result := LowerCase(Trim(Result));
// Un site note "Gitea" plutot qu'en adresse n'a pas d'hote : sans point, il
// n'y a rien a demander au relais.
if Pos('.', Result) = 0 then Result := '';
end;
/// Le domaine de second niveau, ou '' s'il n'y a rien de plus court a essayer.
function SecondLevel(const AHost: string): string;
var
LParts: TArray<string>;
begin
Result := '';
LParts := AHost.Split(['.']);
if Length(LParts) < 3 then Exit; // deja un SLD
Result := LParts[High(LParts) - 1] + '.' + LParts[High(LParts)];
end;
/// Le type d'apres les premiers octets, jamais d'apres l'extension : le relais
/// rend du PNG sous une adresse en .ico, et un type faux ferait afficher un
/// cadre vide sans rien dire.
function SniffMime(const AData: TBytes): string;
begin
Result := '';
if Length(AData) < 4 then Exit;
if (AData[0] = $89) and (AData[1] = Ord('P')) then Result := 'image/png'
else if (AData[0] = $FF) and (AData[1] = $D8) then Result := 'image/jpeg'
else if (AData[0] = Ord('G')) and (AData[1] = Ord('I')) then Result := 'image/gif'
else if (AData[0] = 0) and (AData[1] = 0) and (AData[2] = 1) then
Result := 'image/x-icon'
else if (AData[0] = Ord('<')) then Result := 'image/svg+xml';
end;
function TryHost(const AHost: string; out AData: TBytes): Boolean;
var
LHttp: THTTPClient;
LStream: TBytesStream;
LResp: IHTTPResponse;
begin
Result := False;
AData := nil;
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := TIMEOUT_MS;
LHttp.ResponseTimeout := TIMEOUT_MS;
LHttp.MaxRedirects := 3;
LStream := TBytesStream.Create;
try
try
LResp := LHttp.Get('https://icons.duckduckgo.com/ip3/' + AHost + '.ico',
LStream);
except
Exit;
end;
if LResp.StatusCode <> 200 then Exit;
if (LStream.Size = 0) or (LStream.Size > MAX_BYTES) then Exit;
AData := Copy(LStream.Bytes, 0, LStream.Size);
Result := True;
finally
LStream.Free;
end;
finally
LHttp.Free;
end;
end;
function FetchFaviconDataUri(const ASite: string; out AError: string): string;
var
LHost, LSld, LMime: string;
LData: TBytes;
LB64: TBase64Encoding;
begin
Result := '';
AError := '';
LHost := HostOf(ASite);
if LHost = '' then
begin
AError := 'this entry has no web address';
Exit;
end;
if not TryHost(LHost, LData) then
begin
LSld := SecondLevel(LHost);
if (LSld = '') or not TryHost(LSld, LData) then
begin
AError := 'no icon found for ' + LHost;
Exit;
end;
end;
LMime := SniffMime(LData);
if LMime = '' then
begin
AError := 'what came back is not an image';
Exit;
end;
// SANS COUPURE DE LIGNE. L'encodeur de Delphi coupe a 76 caracteres par
// defaut ; une URI data: coupee ne s'affiche pas, et le piege s'est deja
// paye une fois sur les conteneurs.
LB64 := TBase64Encoding.Create(0);
try
Result := 'data:' + LMime + ';base64,' + LB64.EncodeBytesToString(LData);
finally
LB64.Free;
end;
end;
end.
@@ -0,0 +1,14 @@
// Modele. Copier en PMA.LocalConfig.inc (ignore par git) et remplir.
//
// VALEURS DE PREMIER LANCEMENT SEULEMENT. Une fois que l'appareil a enregistre
// ses reglages, elles ne sont plus lues. Laisser vide est parfaitement viable :
// l'application demande alors de saisir jeton et mot de passe, ce qui est le
// comportement d'une vraie installation.
//
// Le jeton authentifie l'appareil aupres de l'endpoint ; le mot de passe de
// sync ouvre TOUT le coffre une fois le conteneur telecharge. Aucun des deux
// n'a sa place dans un depot.
const
FIRSTRUN_TOKEN = '';
TEST_PWD = '';
+136
View File
@@ -0,0 +1,136 @@
unit PMA.SaveFile;
{
Deposer un fichier la ou l'utilisateur le retrouvera.
LA QUESTION N'ETAIT PAS LE CODE, C'ETAIT L'ENDROIT. Le repertoire prive de
l'application ne sert a rien : aucun gestionnaire de fichiers ne l'ouvre, et
il part avec la desinstallation. Le stockage externe direct est ferme depuis
Android 10. Restent trois portes, et une seule ne demande ni permission ni
ecran supplementaire :
- MediaStore -> Telechargements : le systeme cree l'entree, nous ecrivons
dedans, le fichier apparait dans "Telechargements" avec les autres. AUCUNE
permission depuis l'API 29, parce que l'application n'ecrit que ce qu'elle
a elle-meme insere.
- Le selecteur de destination (ACTION_CREATE_DOCUMENT) donnerait le choix,
mais il exige un retour d'activite, que FMX rend penible.
- Le partage (ACTION_SEND) exigerait un FileProvider declare au manifeste.
Donc MediaStore. En dessous de l'API 29 il faudrait WRITE_EXTERNAL_STORAGE,
une permission qu'on vient justement de retirer des dix defauts de Delphi :
on refuse et on le dit, plutot que de la reprendre pour des versions
d'Android que personne ici n'utilise.
CE QUI SORT DE L'APPLICATION SORT DE SA PROTECTION. Le fichier depose est
en clair, lisible par tout ce qui lit les telechargements. C'est ce que
l'utilisateur demande en appuyant sur "enregistrer", et c'est pour cela que
l'appel le dit dans son nom plutot que de s'appeler "export".
}
interface
uses
System.SysUtils;
/// Ecrit ADonnees dans Telechargements sous AFileName.
/// Rend le nom retenu par le systeme, ou '' en cas d'echec - AError dit alors
/// pourquoi.
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
out AError: string): string;
implementation
{$IF DEFINED(ANDROID)}
// UNE SEULE CLAUSE uses PAR SECTION : deux d'affilee, meme separees par une
// directive, sont une erreur de syntaxe et non un ajout.
uses
Androidapi.Helpers, Androidapi.JNI.JavaTypes, Androidapi.JNI.Os,
Androidapi.JNI.GraphicsContentViewText, Androidapi.JNI.Provider,
// Androidapi.JNI.Net pour Jnet_Uri : le resolveur rend une URI, pas un
// chemin - c'est elle qui designe la ligne que le systeme vient de creer.
Androidapi.JNI.Net, Androidapi.JNIBridge;
const
// MediaStore.MediaColumns, ecrites en dur comme EXTRA_IS_SENSITIVE ailleurs :
// les noms de colonnes sont stables depuis toujours, et les chercher dans les
// en-tetes coute plus que de les lire ici.
COL_NAME = '_display_name';
COL_MIME = 'mime_type';
COL_PATH = 'relative_path';
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
out AError: string): string;
var
LValues: JContentValues;
LUri: Jnet_Uri;
LOut: JOutputStream;
LArr: TJavaArray<Byte>;
LMime: string;
begin
Result := '';
AError := '';
if Length(AData) = 0 then
begin
AError := 'nothing to write';
Exit;
end;
if TJBuild_VERSION.JavaClass.SDK_INT < 29 then
begin
AError := 'saving files needs Android 10 or later';
Exit;
end;
LMime := AMime;
// Un type vide ferait ranger le fichier sans que rien ne sache l'ouvrir.
if LMime = '' then LMime := 'application/octet-stream';
try
LValues := TJContentValues.JavaClass.init;
LValues.put(StringToJString(COL_NAME), StringToJString(AFileName));
LValues.put(StringToJString(COL_MIME), StringToJString(LMime));
// Le systeme se charge des doublons : deux fichiers du meme nom deviennent
// "nom" et "nom (1)". Rien a gerer de notre cote.
LValues.put(StringToJString(COL_PATH), StringToJString('Download'));
LUri := TAndroidHelper.Context.getContentResolver.insert(
TJMediaStore_Downloads.JavaClass.EXTERNAL_CONTENT_URI, LValues);
if LUri = nil then
begin
AError := 'the system refused to create the file';
Exit;
end;
LOut := TAndroidHelper.Context.getContentResolver.openOutputStream(LUri);
if LOut = nil then
begin
AError := 'the system gave no way to write';
Exit;
end;
LArr := TJavaArray<Byte>.Create(Length(AData));
try
Move(AData[0], LArr.Data^, Length(AData));
LOut.write(LArr);
LOut.flush;
finally
LOut.close;
LArr.Free;
end;
Result := AFileName;
except
on E: Exception do
begin
AError := E.ClassName + ': ' + E.Message;
Result := '';
end;
end;
end;
{$ELSE}
function SaveToDownloads(const AFileName, AMime: string; const AData: TBytes;
out AError: string): string;
begin
Result := '';
AError := 'Downloads are Android-only';
end;
{$ENDIF}
end.
+129
View File
@@ -0,0 +1,129 @@
unit PMA.ScreenGuard;
{
FLAG_SECURE : interdit la capture d'ecran et l'apparition du contenu dans
l'apercu des applications recentes.
COMPLEMENTAIRE, pas redondant, avec le masquage sur WillBecomeInactive :
celui-ci traite l'apercu des recentes, celui-la traite en plus les captures
manuelles, l'enregistrement d'ecran et la duplication vers un ecran externe -
qu'aucun code applicatif ne peut intercepter.
ACTIF EN RELEASE SEULEMENT. En debug le drapeau empeche aussi les captures
d'ecran de developpement, ce qui rend le diagnostic penible pour un gain nul
sur un appareil de test. Le verifier demande donc une compilation Release.
}
interface
uses
System.UITypes;
/// A appeler une fois, au demarrage, depuis le thread principal.
procedure SecureScreen;
/// Teinte les barres systeme - etat en haut, navigation en bas.
///
/// Sans cela l'application flotte dans un cadre qui ne lui appartient pas :
/// barre d'etat grise, barre de navigation BLANCHE sous un fond bleu. La
/// reference les fait baigner dans le meme degrade, et c'est ce qui donne
/// l'impression que l'application occupe l'ecran plutot que d'y etre posee.
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
/// Replie le clavier, et dit s'il etait bien deplie.
///
/// LA REPONSE COMPTE AUTANT QUE L'ACTION. La touche retour arrive chez nous
/// avant le clavier - la vue web n'est pas un champ que le systeme connait -
/// et notre gestionnaire la consommait toujours, verrouillant le coffre ou
/// remontant a la liste pendant que le clavier restait deplie, sans plus aucun
/// moyen de le refermer. Savoir s'il y avait quelque chose a replier permet de
/// rendre a la touche son premier sens et de ne consommer que dans ce cas.
///
/// A appeler depuis le thread d'interface, et sans differer : celui qui appelle
/// decide de la suite d'apres la reponse.
function HideSoftKeyboard: Boolean;
implementation
{$IF DEFINED(ANDROID)}
uses
// System.SysUtils : sans elle, TAndroidHelper.GetJActivity reste un appel
// non expanse et le compilateur le signale a chaque usage.
System.SysUtils, Androidapi.JNI.JavaTypes, Androidapi.Helpers, Androidapi.JNI.App,
Androidapi.JNI.GraphicsContentViewText, FMX.Helpers.Android;
procedure SecureScreen;
begin
// La condition est ICI, dans le corps, et non sur la clause uses : celle-ci
// sert aussi a TintSystemBars, qui doit fonctionner en debug.
{$IFNDEF DEBUG}
// setFlags touche la fenetre : thread d'interface obligatoire.
CallInUIThread(
procedure
begin
TAndroidHelper.Activity.getWindow.setFlags(
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE,
TJWindowManager_LayoutParams.JavaClass.FLAG_SECURE);
end);
{$ENDIF}
end;
{$ELSE}
procedure SecureScreen;
begin
// Rien a faire : ce n'est pas Android.
end;
{$ENDIF}
{$IF DEFINED(ANDROID)}
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
begin
CallInUIThread(
procedure
var
LWindow: JWindow;
begin
try
LWindow := TAndroidHelper.Activity.getWindow;
// Les deux couleurs different : le haut prend le depart du degrade,
// le bas son arrivee, pour que la continuite soit exacte.
LWindow.setStatusBarColor(Integer(ATop));
LWindow.setNavigationBarColor(Integer(ABottom));
except
// Une barre non teintee n'empeche rien de fonctionner.
end;
end);
end;
{$ELSE}
procedure TintSystemBars(ATop, ABottom: TAlphaColor);
begin
end;
{$ENDIF}
{$IF DEFINED(ANDROID)}
function HideSoftKeyboard: Boolean;
var
LService: JObject;
LView: JView;
begin
Result := False;
try
LView := TAndroidHelper.Activity.getWindow.getDecorView;
if LView = nil then Exit;
LService := TAndroidHelper.Context.getSystemService(
TJContext.JavaClass.INPUT_METHOD_SERVICE);
if LService = nil then Exit;
// Rend False si rien n'etait deplie : c'est notre reponse.
Result := TJInputMethodManager.Wrap(LService)
.hideSoftInputFromWindow(LView.getWindowToken, 0);
except
// Un clavier qui ne se replie pas ne doit pas emporter la touche retour.
Result := False;
end;
end;
{$ELSE}
function HideSoftKeyboard: Boolean;
begin
Result := False;
end;
{$ENDIF}
end.
File diff suppressed because it is too large Load Diff
+397
View File
@@ -0,0 +1,397 @@
unit PMA.SyncClient;
{
Lecture de l'instantane chiffre. DEUX TRANSPORTS, reconnus a l'URL :
- un depot Gitea prive, via l'API des contenus. C'est la destination : plus
rien a heberger, HTTPS et certificat deja en place, acces depuis
l'exterieur gratuit.
- l'endpoint PHP historique (server-php/sync.php), garde le temps que le
poste Windows publie aussi dans le depot. Sans lui, la migration exigerait
de tout basculer d'un coup, des deux cotes, sans retour possible.
Ne dechiffre rien : il ramene des octets opaques et l'empreinte qui les
identifie. Le dechiffrement est le travail de PMA.Crypto, et il coute
4 secondes - d'ou l'importance de cette empreinte, qui evite de le refaire
quand rien n'a change.
CE QUE L'EMPREINTE DESIGNE DIFFERE selon le transport, mais son role est le
meme et l'appelant n'a pas a le savoir : l'ETag HTTP pour PHP, le sha du blob
pour Gitea. Sur Gitea la comparaison se fait ICI, apres reception : l'API ne
connait pas If-None-Match, donc le corps est retelecharge - ce qui est
bon marche - mais la derivation, elle, est bien evitee.
Le PHP repond 404 aussi bien pour un jeton invalide que pour un instantane
absent : c'est voulu (un appelant non authentifie n'apprend pas ce qui
existe), et le client ne peut donc pas les distinguer. Gitea repond de meme
sur un depot prive. Le message utilisateur doit couvrir les deux cas.
}
interface
uses
System.SysUtils, System.Classes, System.Net.HttpClient,
System.Net.URLClient, System.Net.HttpClientComponent;
type
TSyncStatus = (
ssOK, // 200 : nouveau contenu dans Container
ssNotModified, // 304 : l'ETag detenu est toujours le bon
ssNoSnapshot, // 404 : pas d'instantane, OU jeton invalide
ssRefused, // 403 : HTTPS exige par le serveur
ssHttpError, // autre code
ssNetworkError, // rien n'a repondu
/// 409 ou 422 : le sha envoye n'est plus celui du fichier - un autre appareil a
/// publie entre notre lecture et notre ecriture. L'appelant doit relire,
/// refusionner et renvoyer, pas reessayer tel quel.
ssConflict
);
TSyncResult = record
Status: TSyncStatus;
Container: string; // le JSON chiffre, vide sauf sur ssOK
/// Ce qui identifie ce contenu : ETag HTTP sur PHP, sha du blob sur Gitea.
/// A ranger avec le clair et a repasser au prochain appel.
ETag: string;
HttpCode: Integer;
Error: string; // message brut, pour le journal
end;
TSyncClient = class
private
FBaseUrl: string;
FToken: string;
FTimeoutMs: Integer;
public
constructor Create(const ABaseUrl, AToken: string);
/// GET de l'instantane. AKnownETag non vide -> ssNotModified si le serveur
/// rend le meme contenu, ce qui epargne la derivation de la cle.
function Fetch(const AKnownETag: string = ''): TSyncResult;
/// Vrai si l'URL designe l'API des contenus de Gitea. C'est le seul
/// discriminant : le chemin de cette API est fixe, et une URL de dépôt qui
/// n'y ressemble pas est de toute facon inutilisable.
function IsGitea: Boolean;
/// Publie un conteneur. ASha est celui du fichier qu'on a lu : Gitea s'en
/// sert pour refuser (409) une ecriture si quelqu'un d'autre a publie
/// entre-temps. Vide = creation d'un fichier qui n'existait pas.
///
/// GITEA SEULEMENT pour l'instant. L'endpoint PHP repond 405 au PUT, et
/// lui ajouter l'ecriture n'a plus d'interet maintenant que le depot fait
/// le meme travail avec un controle de concurrence eprouve.
function Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
property BaseUrl: string read FBaseUrl write FBaseUrl;
property Token: string read FToken write FToken;
/// Volontairement court : sur mobile, un reseau qui ne repond pas doit
/// rendre la main vite plutot que de figer l'ecran.
property TimeoutMs: Integer read FTimeoutMs write FTimeoutMs;
end;
implementation
uses
System.JSON, System.NetEncoding;
function TSyncClient.IsGitea: Boolean;
begin
Result := FBaseUrl.ToLower.Contains('/api/v1/repos/');
end;
/// Le sha du fichier ecrit, tire de la reponse au PUT : Gitea rend
/// { "content": { "sha": ... }, "commit": { ... } }. Vide si la forme change -
/// l'appelant relira, ce qui coute un appel mais ne perd rien.
function ShaFromPutResponse(const ABody: string): string;
var
LRoot, LContent: TJSONObject;
begin
Result := '';
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
if LRoot = nil then Exit;
try
if LRoot.TryGetValue<TJSONObject>('content', LContent) then
Result := LContent.GetValue<string>('sha', '');
finally
LRoot.Free;
end;
end;
/// Extrait le conteneur et le sha de la reponse de l'API des contenus. Rend
/// faux, avec la raison, si la reponse n'a pas la forme attendue - une URL de
/// dépôt juste a cote rend du JSON, mais pas celui-la.
function ParseGiteaContents(const ABody: string;
out AContainer, ASha, AError: string): Boolean;
var
LRoot: TJSONObject;
LEncoded, LEncoding: string;
begin
Result := False;
AContainer := '';
ASha := '';
LRoot := TJSONObject.ParseJSONValue(ABody) as TJSONObject;
if LRoot = nil then
begin
AError := 'reponse Gitea illisible (pas du JSON)';
Exit;
end;
try
ASha := LRoot.GetValue<string>('sha', '');
LEncoding := LRoot.GetValue<string>('encoding', '');
LEncoded := LRoot.GetValue<string>('content', '');
if (LEncoded = '') or (ASha = '') then
begin
AError := 'reponse Gitea sans content/sha - URL de contenu correcte ?';
Exit;
end;
if not SameText(LEncoding, 'base64') then
begin
AError := 'encodage Gitea inattendu : ' + LEncoding;
Exit;
end;
// Le base64 de Gitea est decoupe en lignes ; le decodeur s'en accommode,
// mais on ne compte pas dessus.
LEncoded := LEncoded.Replace(#13, '').Replace(#10, '');
try
AContainer := TEncoding.UTF8.GetString(TNetEncoding.Base64.DecodeStringToBytes(LEncoded));
except
on E: Exception do
begin
AError := 'base64 Gitea invalide : ' + E.Message;
Exit;
end;
end;
Result := AContainer.Trim <> '';
if not Result then AError := 'fichier vide dans le depot';
finally
LRoot.Free;
end;
end;
function TSyncClient.Put(const AContainerJson, ASha, AMessage: string): TSyncResult;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LEnv: TJSONObject;
LReq, LResync: TStringStream;
LB64: TBase64Encoding;
begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
if not IsGitea then
begin
Result.Status := ssHttpError;
Result.Error := 'Ecriture disponible seulement vers un depot Gitea';
Exit;
end;
if (FBaseUrl = '') or (FToken = '') then
begin
Result.Error := 'URL ou jeton non configure';
Exit;
end;
LEnv := TJSONObject.Create;
LB64 := TBase64Encoding.Create(0); // pas de coupure : voir PMA.Crypto
try
LEnv.AddPair('message', AMessage);
LEnv.AddPair('content',
LB64.EncodeBytesToString(TEncoding.UTF8.GetBytes(AContainerJson)));
// OMIS, PAS VIDE, quand on cree le fichier : Gitea lit la presence de la
// cle comme « remplace exactement cette version-la », et une chaine vide
// n'est pas la meme chose que son absence.
if ASha <> '' then LEnv.AddPair('sha', ASha);
// `branch` reste absent : le depot utilise alors sa branche par defaut,
// plutot qu'un nom qu'on devinerait.
LReq := TStringStream.Create(LEnv.ToJSON, TEncoding.UTF8);
LResync := TStringStream.Create('', TEncoding.UTF8);
try
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ResponseTimeout := FTimeoutMs;
LHttp.HandleRedirects := False;
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken;
LHttp.CustomHeaders['Content-Type'] := 'application/json';
try
LResp := LHttp.Put(FBaseUrl, LReq, LResync);
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
Exit;
end;
end;
Result.HttpCode := LResp.StatusCode;
case LResp.StatusCode of
200, 201:
begin
Result.Status := ssOK;
// Le sha du fichier VENANT D'ETRE ECRIT, a garder pour la
// prochaine ecriture - sans lui il faudrait relire pour publier.
Result.ETag := ShaFromPutResponse(LResync.DataString);
end;
// 409 ET 422 : DANS LES DEUX CAS LE SHA N'EST PLUS LE BON.
//
// Gitea rend 409 quand le sha envoye ne correspond plus, et 422 quand
// il manque ou qu'il juge la demande irrecevable - dont le cas « ce
// fichier existe deja » d'une creation. Mesure a l'appui : un poste
// qui publie entre notre lecture et notre ecriture nous a valu un
// 422, pas un 409, et l'enregistrement echouait au lieu de reprendre.
//
// Relire et recommencer est la bonne reponse aux deux, et elle est
// sans danger : la reprise republie avec le sha frais, donc elle
// n'ecrase jamais rien. Un vrai corps mal forme echouerait de nouveau
// et sortirait au bout de trois essais, avec le texte de Gitea.
409, 422: Result.Status := ssConflict;
else
Result.Status := ssHttpError;
// LE CORPS, TOUJOURS. Il etait remplace par un message fige sur 401
// et 403 - « jeton sans droit d'ecriture » - ce qui est une
// interpretation, pas une observation : le meme code couvre un depot
// en lecture seule, une branche protegee, un jeton d'un autre compte.
// Gitea dit lequel dans son corps, et le taire envoie chercher a
// cote.
Result.Error := Format('HTTP %d %s - %s', [LResp.StatusCode,
LResp.StatusText, Copy(LResync.DataString, 1, 300)]);
end;
finally
LHttp.Free;
end;
finally
LResync.Free;
LReq.Free;
end;
finally
LB64.Free;
LEnv.Free;
end;
end;
constructor TSyncClient.Create(const ABaseUrl, AToken: string);
begin
inherited Create;
FBaseUrl := ABaseUrl.Trim;
FToken := AToken.Trim;
FTimeoutMs := 15000;
end;
function TSyncClient.Fetch(const AKnownETag: string): TSyncResult;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TStringStream;
begin
Result := Default(TSyncResult);
Result.Status := ssNetworkError;
if (FBaseUrl = '') or (FToken = '') then
begin
Result.Error := 'URL ou jeton non configure';
Exit;
end;
LHttp := THTTPClient.Create;
try
LHttp.ConnectionTimeout := FTimeoutMs;
LHttp.ResponseTimeout := FTimeoutMs;
// Pas de redirection suivie : une redirection vers un autre hote nous
// ferait envoyer le jeton ailleurs.
LHttp.HandleRedirects := False;
// Gitea attend "token <PAT>" pour un jeton d'acces personnel ; le PHP
// attend "Bearer". Envoyer l'un a la place de l'autre donne un 401 qui ne
// dit pas pourquoi.
if IsGitea then
LHttp.CustomHeaders['Authorization'] := 'token ' + FToken
else
begin
LHttp.CustomHeaders['Authorization'] := 'Bearer ' + FToken;
// Pas sur Gitea : le sha n'est pas un ETag HTTP, et l'envoyer ferait
// repondre 200 avec un corps complet de toute facon.
if AKnownETag <> '' then
LHttp.CustomHeaders['If-None-Match'] := AKnownETag;
end;
LStream := TStringStream.Create('', TEncoding.UTF8);
try
try
LResp := LHttp.Get(FBaseUrl, LStream);
except
on E: Exception do
begin
Result.Status := ssNetworkError;
Result.Error := E.ClassName + ': ' + E.Message;
Exit;
end;
end;
Result.HttpCode := LResp.StatusCode;
Result.ETag := LResp.HeaderValue['ETag'];
case LResp.StatusCode of
200:
if IsGitea then
begin
if not ParseGiteaContents(LStream.DataString, Result.Container,
Result.ETag, Result.Error) then
Result.Status := ssHttpError
// Meme sha : le contenu est celui qu'on detient deja. On rend la
// meme reponse qu'un 304 pour que l'appelant epargne la derivation,
// et on jette le conteneur - il ne servirait a rien.
//
// COMPARAISON INSENSIBLE A LA CASSE ET AUX BLANCS. Une empreinte
// hexadecimale ne peut pas differer seulement par la casse ou par
// un espace : trancher ainsi ne peut donc pas confondre deux
// instantanes distincts, alors qu'une egalite stricte se laisse
// battre par un caractere invisible - un guillemet d'en-tete, une
// fin de ligne - et fait payer une derivation complete pour rien.
else if (AKnownETag <> '') and
SameText(Result.ETag.Trim, AKnownETag.Trim) then
begin
Result.Status := ssNotModified;
Result.Container := '';
end
else
Result.Status := ssOK;
end
else
begin
Result.Status := ssOK;
Result.Container := LStream.DataString;
// Un corps vide avec un 200 signale un proxy ou une erreur de
// configuration, pas un instantane : le signaler ici evite un
// "mot de passe incorrect" trompeur plus loin.
if Result.Container.Trim = '' then
begin
Result.Status := ssHttpError;
Result.Error := '200 avec un corps vide';
end;
end;
304: Result.Status := ssNotModified;
403:
begin
Result.Status := ssRefused;
Result.Error := 'Le serveur exige HTTPS';
end;
// Gitea repond 404 sur un depot prive qu'on n'a pas le droit de voir,
// comme sur un fichier absent - exactement l'ambiguite du PHP.
404: Result.Status := ssNoSnapshot;
401:
begin
Result.Status := ssHttpError;
Result.Error := '401 : jeton refuse';
end;
else
Result.Status := ssHttpError;
Result.Error := 'HTTP ' + LResp.StatusCode.ToString + ' ' + LResp.StatusText;
end;
finally
LStream.Free;
end;
finally
LHttp.Free;
end;
end;
end.
+39
View File
@@ -0,0 +1,39 @@
unit PMA.TestVector;
{
The testvector.json container, embedded verbatim.
Generated by a script - do not edit by hand. It exists for the very first
run on a device, where the point is to isolate the crypto: no network, no
file to deploy, so a failure can only come from the decryption itself.
}
interface
const
// Vector password. NON-ASCII on purpose: that is the only case telling a
// correct PBKDF2 from an approximate one (see testvector.md). Written as
// escapes so this file stays pure ASCII and no editor can mangle it.
// e-acute = U+00E9, a-grave = U+00E0, u-diaeresis = U+00FC
TESTVECTOR_PASSWORD = 'sync-test-password-'#$00E9#$00E0#$00FC;
// Expected values, to be checked IN THIS ORDER: the key first, the plaintext
// second. A wrong key points at PBKDF2 or the password encoding; a correct
// key with a failed decryption points at AES-GCM.
TESTVECTOR_KEY_HEX = '6bd7683ea78e163062f02681b03f2906fcf5a3a25025005af4e71982e6e2b286';
TESTVECTOR_PLAIN_SHA = 'ddaee44f8ac8374f1ece6d9ee2abd104d59cd283556f1c4c07afa95ae1dbd2a7';
TESTVECTOR_PLAIN_LEN = 572; // UTF-8 bytes
TESTVECTOR_JSON =
'{' +
' "format": "pm-encrypted-export-v1",' +
' "kdf": "pbkdf2-sha256",' +
' "kdf_iterations": 600000,' +
' "kdf_salt": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",' +
' "iv": "yMnKy8zNzs/Q0dLT",' +
' "ciphertext": "31AqOJ83L2TEFOcYaKEdpqKWGY42I4NUq+jdO7pA3j8+v023ECxjSaEnB0NvbufW6dMCgkK9oFwRvoQlLVSvK6bNBwBinJ4ltlbwKqATR2HGHh5eqiqGZayNNdRIFNJZw/jw9lYy28R6uK421uaMRx+mUBs4LGX9U+F2ayFfVF/VlsaHb5I3YMhLCN2zyMxs3j7Y1s69N09Uonm0LGKQ+SiteuXTHWJzAvHFIduLlxPJhCfEKUap4PolQ5PMohJeJYt+sutg7UY6i0QclxpmF2MY2qpbh3gA2mmSntoI2LN8Q41NMqyVn6JY6kXXpEfD1aNVw6dDCqAw2FQJ9HxE+/GBirt009rdS/l59KEnIYRvgBFnQqRTJZNNncHXXh3TsnQ3opKEyYe5qCxnX6E7wGzRawD/sQn2NNWjR8wwQQ68Cs74/RR8stAeuD/VJhJnkwxXPh8eBXHWFjLgFZuan+Ja2FwkjMhkHvIqbfJpbNwnu1mJ8ABtbGqmEHYSGUvLovIwCoQWjBqewlCFvRg0l4h63mUxuLaXx0SrIA3xhS7HqnYVTBp5/EVAu715jKmeDHviiZw6iws5ujd/u0IDUtJY1uANaF1xTBwcfkCnuonBlI8eMbIlcndrLSUN2slm6KxeUjZ3RSpiE9Izh5ix4kann8lygCAZVGkjswB83LmB1dFNH3oqG3+4qUgv7qR0TRZUSMoopCaSxhsVRd6/uBWu+82Nkbij1jMIzaB1+vTxBEHi5DGBNDfV/0evTgoIkC9wJtlEbz/OVCwC",' +
' "created_at": "2026-08-14T12:00:00.000Z"' +
'}';
implementation
end.
+42
View File
@@ -0,0 +1,42 @@
unit PMA.Theme;
{
La palette, et le fond de la forme.
CE QU'IL RESTE D'UN THEME. Cette unite habillait autrefois des boutons, des
surfaces et des libelles peints a la main, et chargeait un style pour les
controles natifs qu'on ne reecrivait pas. L'interface est passee au moteur du
systeme (android-client/web) : il n'y a plus un seul controle FMX a habiller,
donc tout cela est parti - avec le style vendu, qui n'avait plus rien a styler.
Ce qui subsiste sert au CADRE, que la page ne peut pas atteindre : le fond de
la forme, visible un instant avant le premier rendu du moteur, et les deux
couleurs que prennent les barres systeme. Elles doivent suivre celles de
app.html, sinon la jonction se voit.
}
interface
uses
// FMX.Graphics pour TBrush, que PaintBackground prend en parametre.
System.UITypes, FMX.Types, FMX.Graphics;
const
// Releve sur la recherche globale du telephone : un degrade bleu, des blocs
// qui ne sont que du blanc translucide pose dessus, aucune bordure.
COL_BG_TOP = TAlphaColor($FF3E6394); // haut du degrade
COL_BG = TAlphaColor($FF1B3563); // bas du degrade
/// Applique le degrade bleu du fond. A poser sur le Fill de la forme.
procedure PaintBackground(AFill: TBrush);
implementation
procedure PaintBackground(AFill: TBrush);
begin
AFill.Kind := TBrushKind.Gradient;
AFill.Gradient.Color := COL_BG_TOP;
AFill.Gradient.Color1 := COL_BG;
// Rien de plus : un TGradient neuf descend deja du haut vers le bas.
end;
end.
+152
View File
@@ -0,0 +1,152 @@
unit PMA.Totp;
{
Codes TOTP (RFC 6238) - la raison principale de sortir son telephone.
Rien de specifique a Android : base32, HMAC-SHA1, troncature dynamique. Le
poste Windows a deja son implementation en JavaScript ; celle-ci est la
meme mecanique, verifiee contre les memes vecteurs de la RFC.
Le secret arrive soit en base32 nu, soit en URI otpauth:// - les deux
circulent dans les instantanes selon la facon dont l'entree a ete creee.
On accepte les deux, et l'URI fournit en prime sa periode et son nombre de
chiffres quand ils different des valeurs par defaut.
}
interface
uses
System.SysUtils;
/// Code courant pour ASecret, et le temps restant avant renouvellement.
/// Retourne '' si le secret est vide ou illisible - jamais d'exception : un
/// mauvais secret ne doit pas empecher l'affichage du reste de l'entree.
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
/// Meme chose a un instant donne. Existe pour que les vecteurs de la RFC
/// soient verifiables : ils sont tous exprimes a une date fixe.
function TotpAt(const ASecret: string; AUnix: Int64;
out ASecondsLeft, APeriod: Integer): string;
implementation
uses
System.Classes, System.Hash, System.DateUtils, System.NetEncoding;
const
B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function Base32Decode(const S: string): TBytes;
var
I, LBits, LValue, LIdx, LPos: Integer;
LCh: Char;
begin
SetLength(Result, Length(S)); // majorant : 5 bits par caractere
LBits := 0; LValue := 0; LIdx := 0;
for I := 1 to Length(S) do
begin
LCh := UpCase(S[I]);
// Espaces, tirets et remplissage sont ignores : les secrets se
// recopient souvent par groupes de quatre.
LPos := Pos(LCh, B32);
if LPos = 0 then Continue;
LValue := (LValue shl 5) or (LPos - 1);
Inc(LBits, 5);
if LBits >= 8 then
begin
Result[LIdx] := (LValue shr (LBits - 8)) and $FF;
Inc(LIdx);
Dec(LBits, 8);
end;
end;
SetLength(Result, LIdx);
end;
/// Valeur d'un parametre de requete dans une URI otpauth://, ou '' s'il
/// manque. Assez pour secret/period/digits, sans tirer un analyseur d'URI.
function UriParam(const AUri, AName: string): string;
var
LStart, LEnd: Integer;
LKey: string;
begin
Result := '';
LKey := '?' + AName + '=';
LStart := Pos(LKey, AUri);
if LStart = 0 then
begin
LKey := '&' + AName + '=';
LStart := Pos(LKey, AUri);
if LStart = 0 then Exit;
end;
Inc(LStart, Length(LKey));
LEnd := LStart;
while (LEnd <= Length(AUri)) and (AUri[LEnd] <> '&') do Inc(LEnd);
Result := Copy(AUri, LStart, LEnd - LStart);
end;
function TotpNow(const ASecret: string; out ASecondsLeft, APeriod: Integer): string;
begin
// Toujours en UTC : un decalage horaire ferait un code faux tout en
// paraissant parfaitement valide.
Result := TotpAt(ASecret,
DateTimeToUnix(TTimeZone.Local.ToUniversalTime(Now), True),
ASecondsLeft, APeriod);
end;
function TotpAt(const ASecret: string; AUnix: Int64;
out ASecondsLeft, APeriod: Integer): string;
var
LSecret: string;
LDigits, LOffset, LBinary, LMod, I: Integer;
LKey, LMsg, LHash: TBytes;
LCounter: Int64;
begin
Result := '';
ASecondsLeft := 0;
APeriod := 30;
LDigits := 6;
LSecret := ASecret.Trim;
if LSecret = '' then Exit;
if LSecret.ToLower.StartsWith('otpauth://') then
begin
if UriParam(LSecret, 'period') <> '' then
APeriod := StrToIntDef(UriParam(LSecret, 'period'), 30);
if UriParam(LSecret, 'digits') <> '' then
LDigits := StrToIntDef(UriParam(LSecret, 'digits'), 6);
LSecret := UriParam(LSecret, 'secret');
if LSecret = '' then Exit;
end;
if (APeriod <= 0) or (LDigits < 6) or (LDigits > 8) then Exit;
LKey := Base32Decode(LSecret);
if Length(LKey) = 0 then Exit;
LCounter := AUnix div APeriod;
ASecondsLeft := APeriod - (AUnix mod APeriod);
// Compteur sur 8 octets, gros-boutiste.
SetLength(LMsg, 8);
for I := 7 downto 0 do
begin
LMsg[I] := Byte(LCounter and $FF);
LCounter := LCounter shr 8;
end;
LHash := THashSHA1.GetHMACAsBytes(LMsg, LKey);
if Length(LHash) < 20 then Exit;
// Troncature dynamique : les 4 derniers bits designent l'offset.
LOffset := LHash[19] and $0F;
LBinary := ((LHash[LOffset] and $7F) shl 24) or
((LHash[LOffset + 1] and $FF) shl 16) or
((LHash[LOffset + 2] and $FF) shl 8) or
(LHash[LOffset + 3] and $FF);
LMod := 1;
for I := 1 to LDigits do LMod := LMod * 10;
Result := Format('%.*d', [LDigits, LBinary mod LMod]);
end;
end.
+327
View File
@@ -0,0 +1,327 @@
unit PMA.Vault;
{
Le coffre en memoire : lecture de l'instantane dechiffre, et recherche.
Volontairement des ENREGISTREMENTS et un tableau, pas une hierarchie
d'objets : l'instantane est lu d'un bloc, consulte, puis jete au
verrouillage. Rien ne justifie de la mutabilite ni des references croisees.
CE QUE CETTE UNITE MANIPULE EST EN CLAIR. Elle ne doit rien ecrire sur le
disque : la persistance passe par PMA.Store, qui chiffre.
}
interface
uses
System.SysUtils, System.Classes, System.JSON, System.Generics.Collections;
type
/// Un champ personnalise. Le poste Windows nomme la cle "is_secret" - c'est
/// lui qui decide si la valeur s'affiche masquee.
TVaultField = record
Caption, Value: string;
Secret: Boolean;
end;
/// Metadonnees d'une piece jointe. LE CONTENU N'EST PAS RETENU : il voyage
/// en base64 dans l'instantane et pese lourd - une seule piece jointe a fait
/// passer le cache de 13 a 174 Ko. Le garder en memoire pour tout le coffre
/// alors que rien ne l'affiche serait payer sans rien acheter. Le jour ou
/// l'enregistrement existera, il se relira depuis le JSON brut.
TVaultAttachment = record
FileName, Mime: string;
SizeBytes: Int64;
end;
TVaultEntry = record
Uuid, Title, Site, Username, Password, Folder, Tags, Kind, Totp: string;
/// Sous-type (carte, cle SSH, serveur...). Vide pour un identifiant ou
/// une note ordinaire.
Template: string;
/// Icone en data URI. Non chiffree, purement cosmetique.
IconB64: string;
/// ISO 8601 UTC. Sert d'abord a l'affichage ; ce sera l'arbitre de la
/// fusion le jour ou le client ecrira.
UpdatedAt: string;
Fields: TArray<TVaultField>;
Attachments: TArray<TVaultAttachment>;
Deleted: Boolean;
/// Le corps d'une note EST son mot de passe cote stockage : il n'y a pas
/// de colonne separee. L'affichage doit donc l'intituler autrement.
function IsNote: Boolean;
/// Ce qu'on affiche en tete de ligne : le nom donne par l'utilisateur,
/// sinon le site, sinon l'identifiant. Une entree sans aucun des trois
/// existe (note vide) et ne doit pas apparaitre comme une ligne muette.
function Display: string;
end;
TVault = class
private
FEntries: TArray<TVaultEntry>;
FFolders: TArray<string>;
FUsername: string;
FExportedAt: string;
public
/// Remplit depuis le JSON dechiffre. Leve si la structure est absente.
procedure LoadFromJson(const APlainJson: string);
/// Efface le contenu. Appele au verrouillage.
procedure Clear;
/// Indices des entrees correspondant a AQuery, TRIES par nom affiche -
/// l'ordre de l'instantane est celui de la base, donc arbitraire a l'oeil.
/// Requete vide = tout ce qui n'est pas en corbeille.
///
/// La recherche porte sur le nom, le site, l'identifiant et les tags -
/// PAS sur le mot de passe : personne ne cherche par mot de passe, et le
/// comparer allongerait inutilement son sejour en memoire de travail.
/// Comme les etiquettes sont couvertes par la recherche texte, il n'y a
/// pas de filtre d'etiquette separe : taper son nom suffit.
///
/// AFolder vide = tous les dossiers.
function Search(const AQuery: string; const AFolder: string = '';
AIncludeTrash: Boolean = False): TArray<Integer>;
/// Dossiers declares par l'instantane, dans son ordre.
property Folders: TArray<string> read FFolders;
function Count: Integer;
function LiveCount: Integer;
function TrashCount: Integer;
function Entry(AIndex: Integer): TVaultEntry;
property Username: string read FUsername;
property ExportedAt: string read FExportedAt;
end;
implementation
uses
System.Character, System.Generics.Defaults;
{ TVaultEntry }
function TVaultEntry.IsNote: Boolean;
begin
Result := SameText(Kind, 'note');
end;
function TVaultEntry.Display: string;
begin
Result := Title.Trim;
if Result = '' then Result := Site.Trim;
if Result = '' then Result := Username.Trim;
if Result = '' then Result := '(no name)';
end;
{ TVault }
procedure TVault.Clear;
begin
SetLength(FEntries, 0);
SetLength(FFolders, 0);
FUsername := '';
FExportedAt := '';
end;
/// Les tags arrivent en tableau JSON depuis nos conteneurs, mais en chaine
/// separee par des virgules depuis un import CSV etranger. On accepte les deux.
function TagsToString(const AVal: TJSONValue): string;
var
LArr: TJSONArray;
I: Integer;
begin
Result := '';
if AVal = nil then Exit;
if AVal is TJSONArray then
begin
LArr := AVal as TJSONArray;
for I := 0 to LArr.Count - 1 do
begin
if Result <> '' then Result := Result + ', ';
Result := Result + LArr.Items[I].Value;
end;
end
else
Result := AVal.Value;
end;
/// Les champs personnalises : tableau d'objets {label, value, is_secret}.
/// Un champ sans intitule ET sans valeur ne vaut pas une ligne a l'ecran.
function ParseFields(const AVal: TJSONValue): TArray<TVaultField>;
var
LArr: TJSONArray;
LObj: TJSONObject;
I, N: Integer;
begin
Result := nil;
if not (AVal is TJSONArray) then Exit;
LArr := AVal as TJSONArray;
SetLength(Result, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
LObj := LArr.Items[I] as TJSONObject;
Result[N].Caption := LObj.GetValue<string>('label', '');
Result[N].Value := LObj.GetValue<string>('value', '');
Result[N].Secret := LObj.GetValue<Boolean>('is_secret', False);
if (Result[N].Caption = '') and (Result[N].Value = '') then Continue;
Inc(N);
end;
SetLength(Result, N);
end;
/// Pieces jointes : {filename, mime, size_bytes, content_b64}. On lit tout
/// SAUF content_b64 - voir TVaultAttachment.
function ParseAttachments(const AVal: TJSONValue): TArray<TVaultAttachment>;
var
LArr: TJSONArray;
LObj: TJSONObject;
I, N: Integer;
begin
Result := nil;
if not (AVal is TJSONArray) then Exit;
LArr := AVal as TJSONArray;
SetLength(Result, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
LObj := LArr.Items[I] as TJSONObject;
Result[N].FileName := LObj.GetValue<string>('filename', '');
Result[N].Mime := LObj.GetValue<string>('mime', '');
Result[N].SizeBytes := LObj.GetValue<Int64>('size_bytes', 0);
if Result[N].FileName = '' then Continue;
Inc(N);
end;
SetLength(Result, N);
end;
procedure TVault.LoadFromJson(const APlainJson: string);
var
LRoot, LObj: TJSONObject;
LArr: TJSONArray;
I, N: Integer;
begin
Clear;
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise Exception.Create('Snapshot unreadable: this is not JSON');
try
FUsername := LRoot.GetValue<string>('username', '');
FExportedAt := LRoot.GetValue<string>('exported_at',
LRoot.GetValue<string>('snapshot_at', ''));
// Dossiers : l'instantane porte {name, color, icon}, seul le nom sert ici.
// Un instantane ancien peut ne pas avoir le tableau du tout.
if LRoot.TryGetValue<TJSONArray>('folders', LArr) then
begin
SetLength(FFolders, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
FFolders[N] := (LArr.Items[I] as TJSONObject).GetValue<string>('name', '');
if FFolders[N] <> '' then Inc(N);
end;
SetLength(FFolders, N);
end;
if not LRoot.TryGetValue<TJSONArray>('entries', LArr) then
raise Exception.Create('Snapshot has no "entries" array');
SetLength(FEntries, LArr.Count);
N := 0;
for I := 0 to LArr.Count - 1 do
begin
if not (LArr.Items[I] is TJSONObject) then Continue;
LObj := LArr.Items[I] as TJSONObject;
FEntries[N].Uuid := LObj.GetValue<string>('uuid', '');
FEntries[N].Title := LObj.GetValue<string>('title', '');
FEntries[N].Site := LObj.GetValue<string>('site', '');
FEntries[N].Username := LObj.GetValue<string>('username', '');
FEntries[N].Password := LObj.GetValue<string>('password', '');
FEntries[N].Folder := LObj.GetValue<string>('folder', '');
FEntries[N].Kind := LObj.GetValue<string>('kind', 'login');
FEntries[N].Totp := LObj.GetValue<string>('totp_secret', '');
FEntries[N].Tags := TagsToString(LObj.GetValue('tags'));
FEntries[N].Fields := ParseFields(LObj.GetValue('custom_fields'));
FEntries[N].Attachments := ParseAttachments(LObj.GetValue('attachments'));
FEntries[N].Template := LObj.GetValue<string>('template', '');
FEntries[N].IconB64 := LObj.GetValue<string>('icon_b64', '');
FEntries[N].UpdatedAt := LObj.GetValue<string>('updated_at', '');
// deleted arrive en 0/1 depuis nos conteneurs ; absent = vivant.
FEntries[N].Deleted := LObj.GetValue<Integer>('deleted', 0) = 1;
Inc(N);
end;
SetLength(FEntries, N);
finally
LRoot.Free;
end;
end;
function TVault.Count: Integer;
begin
Result := Length(FEntries);
end;
function TVault.LiveCount: Integer;
var E: TVaultEntry;
begin
Result := 0;
for E in FEntries do if not E.Deleted then Inc(Result);
end;
function TVault.TrashCount: Integer;
begin
Result := Count - LiveCount;
end;
function TVault.Entry(AIndex: Integer): TVaultEntry;
begin
Result := FEntries[AIndex];
end;
function TVault.Search(const AQuery: string; const AFolder: string;
AIncludeTrash: Boolean): TArray<Integer>;
var
LQ: string;
LRes: TList<Integer>;
I: Integer;
E: TVaultEntry;
function Matches(const E: TVaultEntry): Boolean;
begin
Result := (LQ = '')
or (Pos(LQ, E.Title.ToLower) > 0)
or (Pos(LQ, E.Site.ToLower) > 0)
or (Pos(LQ, E.Username.ToLower) > 0)
or (Pos(LQ, E.Tags.ToLower) > 0);
end;
begin
LQ := AQuery.Trim.ToLower;
LRes := TList<Integer>.Create;
try
for I := 0 to High(FEntries) do
begin
E := FEntries[I];
if E.Deleted and not AIncludeTrash then Continue;
if (AFolder <> '') and not SameText(E.Folder, AFolder) then Continue;
if Matches(E) then LRes.Add(I);
end;
Result := LRes.ToArray;
finally
LRes.Free;
end;
// Tri sur le nom affiche, insensible a la casse et a l'accentuation faible
// de CompareText - suffisant pour une liste qu'on parcourt a l'oeil.
TArray.Sort<Integer>(Result, TComparer<Integer>.Construct(
function(const A, B: Integer): Integer
begin
Result := CompareText(FEntries[A].Display, FEntries[B].Display);
end));
end;
end.
+630
View File
@@ -0,0 +1,630 @@
unit PMA.VaultPatch;
{
Modifier UN champ d'UNE entree dans l'instantane, sans toucher au reste.
LA CONTRAINTE QUI GOUVERNE CETTE UNITE. TVault ne lit qu'une partie des
champs : il ignore `content_b64` des pieces jointes, `created_at`, l'avatar du
compte, les tombstones, et tout ce que le poste ajoutera demain. Reserialiser
le coffre a partir de ce qu'il a compris EFFACERAIT tout cela - et le poste
adopterait cette version amputee au tirage suivant, la fusion etant un
dernier-ecrit-gagne sur `updated_at`. La perte serait silencieuse et
definitive.
Donc on ne reconstruit rien : on prend le JSON RECU, on change une valeur
dedans, et on rend le reste tel quel. Ce que le telephone ne comprend pas
voyage intact, par construction plutot que par vigilance.
COROLLAIRE SUR LA FUSION. Comme le patch s'applique a l'instantane qu'on
vient de tirer, il n'y a pas de fusion a ecrire : ce qui n'est pas touche est
deja la version la plus recente du serveur. Le seul conflit possible est
qu'un autre appareil publie entre notre lecture et notre ecriture, et Gitea
le refuse par le sha (409) - il suffit alors de recommencer.
}
interface
uses
System.SysUtils, System.JSON, System.DateUtils;
type
EPMAPatch = class(Exception);
/// Remplace AField de l'entree AUuid par AValue, et bump `updated_at`.
/// Rend le JSON complet, tout le reste inchange. Leve si l'entree est absente -
/// ce qui veut dire que le distant ne la contient pas (supprimee ailleurs), et
/// ecrire quand meme la ferait reapparaitre.
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
/// Un instantane vide, au nom de ce compte. Sert a l'inscription depuis le
/// telephone : meme forme que ce qu'ecrit le poste, pour qu'un coffre ne
/// naisse pas dans un dialecte a part.
function NewEmptySnapshot(const AUsername: string): string;
/// Ajoute une entree a l'instantane et rend le JSON complet.
///
/// LA MEME FORME QUE CE QU'ECRIT LE POSTE, cles vides comprises : une entree
/// nee ici sera relue la-bas, et une cle absente ne se comporte pas toujours
/// comme une cle vide. `folder` vaut 'All' parce que c'est le dossier par
/// defaut du poste, pas parce que c'est joli.
///
/// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore.
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind, AFolder, ATotp: string): string;
/// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place.
///
/// LES DEUX, SINON RIEN. Retirer la ligne sans la tombstone ne supprime pas :
/// le poste tient toujours la sienne, et le prochain tirage la ramenerait comme
/// une entree qu'on n'a jamais eue. C'est la tombstone qui dit "celle-ci a ete
/// supprimee, et a telle heure" - et l'heure compte, parce que l'arbitrage du
/// poste garde une entree modifiee APRES la suppression plutot que de la tuer
/// une seconde fois.
///
/// SANS RETOUR depuis le telephone : il n'y a pas de corbeille ici. Celle du
/// poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation ;
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
function DeleteEntry(const APlainJson, AUuid: string): string;
/// Duplique une entree : meme contenu, uuid neuf, nom suffixe.
///
/// COPIE INTEGRALE, pieces jointes comprises. Le poste fait de meme, et une
/// copie qui perdrait le document attache serait une surprise le jour ou l'on
/// s'en apercevrait - c'est-a-dire trop tard. Le prix est un instantane plus
/// lourd a publier ; il se voit tout de suite, lui.
function DuplicateEntry(const APlainJson, AUuid: string): string;
/// Le contenu base64 d'une piece jointe, relu dans l'instantane BRUT.
///
/// TVault le jette expres a la lecture - une seule piece jointe faisait passer
/// le cache de 13 a 174 Ko, et le garder en memoire pour tout le coffre alors
/// que rien ne l'affiche serait payer sans rien acheter. Il ne se relit donc
/// qu'ici, au moment ou quelqu'un demande a l'enregistrer, et pour une seule
/// entree.
///
/// Rend '' si l'entree, le rang ou le contenu manquent - une piece jointe
/// listee sans contenu existe : le poste peut avoir echoue a le joindre a
/// l'instantane.
function AttachmentContent(const APlainJson, AUuid: string;
AIndex: Integer): string;
/// Pose l'icone d'une entree, et le drapeau de provenance avec elle.
///
/// PAS PatchEntryField : `icon_custom` est un NOMBRE dans l'instantane, et
/// l'ecrire comme une chaine donnerait `"icon_custom": "0"` - que le poste lit
/// avec GetValue<Boolean>, qui echouerait sur une chaine. Deux champs de types
/// differents, donc une fonction qui les connait tous les deux.
///
/// AUri vide efface l'icone. Le drapeau retombe alors a zero comme sur le
/// poste : une icone absente n'est pas une icone choisie.
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
ACustom: Boolean): string;
/// Les anciens mots de passe d'une entree, relus dans l'instantane BRUT.
///
/// A LA DEMANDE, ET JAMAIS DANS TVault. Les charger avec le coffre les
/// mettrait en clair en memoire pour TOUTES les entrees des l'ouverture, alors
/// qu'on en regarde une de temps en temps. Meme raison que pour les pieces
/// jointes, mais pour l'exposition plutot que pour le poids : un ancien mot de
/// passe est souvent encore valide ailleurs.
///
/// Rend une ligne par version, la plus recente d'abord, sous la forme
/// `date<TAB>mot de passe`. Vide s'il n'y en a pas - le poste ne l'envoie que
/// depuis peu, et un instantane plus ancien n'en porte aucun.
function PasswordHistory(const APlainJson, AUuid: string): TArray<string>;
/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat.
///
/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne
/// sait pas reconstruire - le contenu des pieces jointes, l'avatar du compte,
/// la couleur des dossiers, et tout ce que le poste ajoutera demain. Repartir
/// du local amputerait tout cela, et le poste adopterait la version amputee au
/// tirage suivant : la meme perte silencieuse et definitive contre laquelle
/// cette unite entiere est ecrite.
///
/// L'arbitrage est celui du poste, terme a terme : dernier ecrit gagnant sur
/// `updated_at`, et une pierre tombale l'emporte sur une entree plus ancienne
/// qu'elle. Les horodatages sont de l'ISO 8601 UTC des deux cotes,
/// millisecondes comprises, donc l'ordre lexicographique EST l'ordre
/// chronologique - aucune date a analyser.
///
/// Une entree sans uuid est ignoree : rien ne permettrait de la reconnaitre
/// chez l'autre, et l'ajouter en creerait un doublon a chaque fusion.
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
/// Horodatage UTC au format que tout le reste emploie (toISOString cote JS).
/// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une
/// fois : il n'y a qu'une source d'heure ici, et elle est en UTC.
function NowUtcIso: string;
implementation
function NowUtcIso: string;
begin
Result := FormatDateTime('yyyy-mm-dd"T"hh:nn:ss.zzz"Z"',
TTimeZone.Local.ToUniversalTime(Now));
end;
function NewEmptySnapshot(const AUsername: string): string;
var
LRoot: TJSONObject;
begin
LRoot := TJSONObject.Create;
try
// Les memes cles que buildSyncSnapshot, tableaux vides compris : un lecteur
// qui trouve la cle absente et un lecteur qui la trouve vide ne se
// comportent pas forcement pareil, et ce coffre sera lu par le poste.
LRoot.AddPair('version', TJSONNumber.Create(1));
LRoot.AddPair('snapshot_at', NowUtcIso);
LRoot.AddPair('username', AUsername);
LRoot.AddPair('folders', TJSONArray.Create);
LRoot.AddPair('avatar_b64', '');
LRoot.AddPair('entries', TJSONArray.Create);
LRoot.AddPair('tombstones', TJSONArray.Create);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
/// L'heure de suppression retenue pour un uuid, ou '' s'il n'en a pas.
/// La plus recente des deux listes : une entree ressuscitee puis resupprimee
/// doit rester supprimee.
function TombstoneTime(const AStones: TJSONArray; const AUuid: string): string;
var
I: Integer;
LObj: TJSONObject;
LWhen: string;
begin
Result := '';
if AStones = nil then Exit;
for I := 0 to AStones.Count - 1 do
begin
if not (AStones.Items[I] is TJSONObject) then Continue;
LObj := AStones.Items[I] as TJSONObject;
if LObj.GetValue<string>('uuid', '') <> AUuid then Continue;
LWhen := LObj.GetValue<string>('deleted_at', '');
if LWhen > Result then Result := LWhen;
end;
end;
/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et
/// le seul qui n'oublie aucun champ.
/// Inscrit un dossier dans la liste du coffre s'il n'y figure pas deja.
///
/// Ranger une entree dans un dossier ne suffit pas : c'est `folders` qui peuple
/// la colonne du poste, et un nom qui n'y figure pas donne une entree rangee
/// dans un dossier que rien n'affiche - elle semble disparue. Les deux vont
/// donc ensemble, et 'All' ne s'inscrit jamais : c'est le defaut, pas un
/// dossier.
procedure EnsureFolder(const ARoot: TJSONObject; const AName: string);
var
LFolders: TJSONArray;
LObj: TJSONObject;
I: Integer;
begin
if (AName = '') or (AName = 'All') then Exit;
if not ARoot.TryGetValue<TJSONArray>('folders', LFolders) then
begin
LFolders := TJSONArray.Create;
ARoot.AddPair('folders', LFolders);
end;
for I := 0 to LFolders.Count - 1 do
if (LFolders.Items[I] is TJSONObject) and
((LFolders.Items[I] as TJSONObject).GetValue<string>('name', '') = AName) then
Exit;
LObj := TJSONObject.Create;
LObj.AddPair('name', AName);
// Le poste lit aussi 'color' et 'icon' ; vides, il met ses defauts.
LObj.AddPair('color', '');
LObj.AddPair('icon', '');
LFolders.AddElement(LObj);
end;
function CloneJson(const AObj: TJSONObject): TJSONObject;
begin
Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject;
end;
function MergeSnapshots(const ALocalJson, ARemoteJson: string): string;
var
LLocal, LMerged: TJSONObject;
LLocalEntries, LOutEntries, LLocalStones, LOutStones,
LLocalFolders: TJSONArray;
LEntry, LOther: TJSONObject;
I, J: Integer;
LUuid, LMine, LTheirs, LKilled: string;
LFound: Boolean;
begin
LLocal := TJSONObject.ParseJSONValue(ALocalJson) as TJSONObject;
if LLocal = nil then raise EPMAPatch.Create('Instantane local illisible');
try
LMerged := TJSONObject.ParseJSONValue(ARemoteJson) as TJSONObject;
if LMerged = nil then raise EPMAPatch.Create('Instantane distant illisible');
try
if not LMerged.TryGetValue<TJSONArray>('entries', LOutEntries) then
raise EPMAPatch.Create('Instantane distant sans tableau "entries"');
if not LMerged.TryGetValue<TJSONArray>('tombstones', LOutStones) then
begin
LOutStones := TJSONArray.Create;
LMerged.AddPair('tombstones', LOutStones);
end;
// 0. LES DOSSIERS DU LOCAL REJOIGNENT CEUX DU DISTANT.
//
// Oubli trouve a la relecture : la fusion ne portait qu'entrees et
// pierres tombales. Un dossier cree ici - hors connexion, ou juste avant
// qu'un autre appareil publie - disparaissait donc de `folders`, tandis
// que les entrees gardaient son nom. Elles se retrouvaient rangees dans
// un dossier que rien n'affiche : introuvables sans le savoir.
//
// Additif, jamais destructif : on n'enleve pas un dossier du distant
// parce qu'il manque ici, c'est aussi la regle du poste.
if LLocal.TryGetValue<TJSONArray>('folders', LLocalFolders) then
for I := 0 to LLocalFolders.Count - 1 do
if LLocalFolders.Items[I] is TJSONObject then
EnsureFolder(LMerged,
(LLocalFolders.Items[I] as TJSONObject).GetValue<string>('name', ''));
// 1. Les pierres tombales locales rejoignent celles du distant. Avant les
// entrees : l'etape suivante s'en sert pour arbitrer.
if LLocal.TryGetValue<TJSONArray>('tombstones', LLocalStones) then
for I := 0 to LLocalStones.Count - 1 do
if LLocalStones.Items[I] is TJSONObject then
LOutStones.AddElement(CloneJson(LLocalStones.Items[I] as TJSONObject));
// 2. Chaque entree locale : absente du distant on l'ajoute, presente on
// garde la plus recente des deux.
if LLocal.TryGetValue<TJSONArray>('entries', LLocalEntries) then
for I := 0 to LLocalEntries.Count - 1 do
begin
if not (LLocalEntries.Items[I] is TJSONObject) then Continue;
LEntry := LLocalEntries.Items[I] as TJSONObject;
LUuid := LEntry.GetValue<string>('uuid', '');
if LUuid = '' then Continue;
LMine := LEntry.GetValue<string>('updated_at', '');
LFound := False;
for J := 0 to LOutEntries.Count - 1 do
begin
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
LOther := LOutEntries.Items[J] as TJSONObject;
if LOther.GetValue<string>('uuid', '') <> LUuid then Continue;
LFound := True;
LTheirs := LOther.GetValue<string>('updated_at', '');
if LMine > LTheirs then
begin
LOutEntries.Remove(J).Free;
LOutEntries.AddElement(CloneJson(LEntry));
end;
Break;
end;
if not LFound then LOutEntries.AddElement(CloneJson(LEntry));
end;
// 3. Une pierre tombale posterieure a l'entree la retire. Posterieure
// SEULEMENT : une entree modifiee apres coup a ete ressuscitee
// volontairement, et la retuer effacerait ce geste.
for J := LOutEntries.Count - 1 downto 0 do
begin
if not (LOutEntries.Items[J] is TJSONObject) then Continue;
LOther := LOutEntries.Items[J] as TJSONObject;
LKilled := TombstoneTime(LOutStones, LOther.GetValue<string>('uuid', ''));
if (LKilled <> '') and (LKilled > LOther.GetValue<string>('updated_at', '')) then
LOutEntries.Remove(J).Free;
end;
Result := LMerged.ToJSON;
finally
LMerged.Free;
end;
finally
LLocal.Free;
end;
end;
function PasswordHistory(const APlainJson, AUuid: string): TArray<string>;
var
LRoot, LEntry, LRow: TJSONObject;
LEntries, LHist: TJSONArray;
I, J: Integer;
begin
Result := nil;
if AUuid = '' then Exit;
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then Exit;
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then Exit;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
if not LEntry.TryGetValue<TJSONArray>('password_history', LHist) then Exit;
// J, PAS I : la boucle exterieure cherche encore l'entree, et Delphi
// refuse - a juste titre - qu'on lui reecrive son compteur.
for J := 0 to LHist.Count - 1 do
begin
if not (LHist.Items[J] is TJSONObject) then Continue;
LRow := LHist.Items[J] as TJSONObject;
Result := Result + [LRow.GetValue<string>('changed_at', '') + #9 +
LRow.GetValue<string>('password', '')];
end;
Exit;
end;
finally
LRoot.Free;
end;
end;
function PatchEntryIcon(const APlainJson, AUuid, AUri: string;
ACustom: Boolean): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
LEntry.RemovePair('icon_b64').Free;
LEntry.AddPair('icon_b64', AUri);
LEntry.RemovePair('icon_custom').Free;
LEntry.AddPair('icon_custom',
TJSONNumber.Create(Ord(ACustom and (AUri <> ''))));
LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso);
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function AttachmentContent(const APlainJson, AUuid: string;
AIndex: Integer): string;
var
LRoot, LEntry: TJSONObject;
LEntries, LAtt: TJSONArray;
I: Integer;
begin
Result := '';
if (AUuid = '') or (AIndex < 0) then Exit;
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then Exit;
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then Exit;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
if not LEntry.TryGetValue<TJSONArray>('attachments', LAtt) then Exit;
if AIndex >= LAtt.Count then Exit;
if not (LAtt.Items[AIndex] is TJSONObject) then Exit;
Result := (LAtt.Items[AIndex] as TJSONObject)
.GetValue<string>('content_b64', '');
Exit;
end;
finally
LRoot.Free;
end;
end;
function DuplicateEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LCopy: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LNow, LTitle: string;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a dupliquer');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LCopy := nil;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// Reparser plutot que recopier champ par champ : c'est le seul moyen qui
// n'oublie rien de ce que le telephone ne comprend pas.
LCopy := CloneJson(LEntry);
Break;
end;
if LCopy = nil then
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
LNow := NowUtcIso;
LCopy.RemovePair('uuid').Free;
LCopy.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
LCopy.RemovePair('created_at').Free;
LCopy.AddPair('created_at', LNow);
LCopy.RemovePair('updated_at').Free;
LCopy.AddPair('updated_at', LNow);
// Le nom change, sinon les deux lignes sont indiscernables dans la liste.
// Retirer PUIS ajouter, comme partout ici : AddPair seul laisserait deux
// paires 'title', et le lecteur prendrait la premiere - donc l'ancienne.
LTitle := Trim(LCopy.GetValue<string>('title', '') + ' (copy)');
LCopy.RemovePair('title').Free;
LCopy.AddPair('title', LTitle);
LEntries.AddElement(LCopy);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function DeleteEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LStone: TJSONObject;
LEntries, LStones: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a supprimer chez le distant');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// Remove REND la valeur retiree : a l'appelant de la liberer.
LEntries.Remove(I).Free;
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt(
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
// Le tableau peut manquer : un conteneur ecrit par un poste qui n'avait
// encore rien supprime n'a aucune raison de le porter.
if not LRoot.TryGetValue<TJSONArray>('tombstones', LStones) then
begin
LStones := TJSONArray.Create;
LRoot.AddPair('tombstones', LStones);
end;
LStone := TJSONObject.Create;
LStone.AddPair('uuid', AUuid);
LStone.AddPair('deleted_at', NowUtcIso);
LStones.AddElement(LStone);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind, AFolder, ATotp: string): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
LNow: string;
begin
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Snapshot is not a JSON object');
try
LEntries := LRoot.GetValue('entries') as TJSONArray;
if LEntries = nil then
raise EPMAPatch.Create('Snapshot has no entries array');
LNow := NowUtcIso;
LEntry := TJSONObject.Create;
// Minuscules et sans accolades : la forme que tout le reste emploie.
LEntry.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
LEntry.AddPair('title', ATitle);
LEntry.AddPair('site', ASite);
LEntry.AddPair('username', AUsername);
// Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de
// colonne separee, ni ici ni sur le poste.
LEntry.AddPair('password', APassword);
if AFolder <> '' then LEntry.AddPair('folder', AFolder)
else LEntry.AddPair('folder', 'All');
EnsureFolder(LRoot, AFolder);
LEntry.AddPair('tags', TJSONArray.Create);
LEntry.AddPair('totp_secret', ATotp);
LEntry.AddPair('kind', AKind);
LEntry.AddPair('template', '');
LEntry.AddPair('custom_fields', TJSONArray.Create);
LEntry.AddPair('attachments', TJSONArray.Create);
LEntry.AddPair('icon_b64', '');
LEntry.AddPair('icon_custom', TJSONNumber.Create(0));
LEntry.AddPair('created_at', LNow);
LEntry.AddPair('updated_at', LNow);
LEntries.AddElement(LEntry);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function PatchEntryField(const APlainJson, AUuid, AField, AValue: string): string;
var
LRoot, LEntry: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LFound: Boolean;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a retrouver chez le distant');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LFound := False;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// RemovePair puis AddPair : AddPair seul ajouterait un DOUBLON, et le
// lecteur prendrait le premier - donc l'ancienne valeur.
LEntry.RemovePair(AField).Free;
LEntry.AddPair(AField, AValue);
// Sans ce bump, la modification voyage mais les autres appareils
// l'ignorent : leur arbitrage compare `updated_at`, et le notre serait
// reste plus ancien que le leur.
LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso);
if AField = 'folder' then EnsureFolder(LRoot, AValue);
LFound := True;
Break;
end;
if not LFound then
raise EPMAPatch.CreateFmt(
'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
end.
+304
View File
@@ -0,0 +1,304 @@
unit PMA.WebUI;
{
La page HTML et les donnees qu'on lui envoie.
POURQUOI HTML. Les controles FMX n'ont pas de gabarit fiable sur mobile :
la journee a vu disparaitre tour a tour la ligne de liste, l'invite de
recherche, la ligne de detail, selon le style charge. Le reste - arrondis,
ordre d'alignement, HitTest qui avale un appui, vues natives qui se peignent
par-dessus - se re-decouvre a chaque ecran. Le moteur du systeme rend la
meme interface en quelques lignes de CSS, gere l'arabe et le bidirectionnel
seul, et c'est deja le langage de l'interface du poste Windows.
CE QUI RESTE EN DELPHI : Keystore, biometrie, presse-papiers sensible,
FLAG_SECURE, reseau et dechiffrement. Le moteur n'est qu'une surface
d'affichage - il ne detient jamais de quoi ouvrir le coffre.
CE QUI PART VERS LA PAGE. La liste porte les noms, sites et identifiants,
soit ce qui est deja a l'ecran, et AUCUN mot de passe : copier depuis une
ligne envoie un indice, Delphi copie depuis le coffre. Seule l'entree
ouverte a ses valeurs dans le tas JS, une a la fois.
}
interface
uses
PMA.Vault;
/// La page, telle qu'embarquee dans le .res. ASCII pur - voir l'en-tete de
/// app.html : le chemin de chargement de FMX ne transporte pas l'encodage.
function PageHtml: string;
/// Ce que la liste affiche et cherche. Les indices sont ceux du coffre, pas
/// ceux de la liste : la page filtre et trie sans rien redemander, donc les
/// deux ne coincident plus des la premiere frappe.
function VaultToJson(AVault: TVault): string;
/// Les lignes d'une entree, dans l'ordre d'affichage.
function DetailToJson(const AEntry: TVaultEntry): string;
/// Une ligne du detail par son rang, dans le MEME ordre que DetailToJson : la
/// page renvoie un rang, jamais une valeur, et Delphi relit le clair chez lui.
/// Faux si le rang ne designe rien.
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
out ACaption, AValue: string): Boolean; overload;
/// Variante qui rend aussi le rang de la piece jointe, ou -1.
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean; overload;
implementation
uses
// System.Types pour RT_RCDATA, que Winapi.Windows fournirait sur un poste.
System.SysUtils, System.Classes, System.Types, System.JSON, System.Character;
{$R 'web\web.res' 'web\web.rc'}
function PageHtml: string;
var
LStream: TResourceStream;
LBytes: TBytes;
begin
LStream := TResourceStream.Create(HInstance, 'PMAPP', RT_RCDATA);
try
SetLength(LBytes, LStream.Size);
LStream.ReadBuffer(LBytes, LStream.Size);
Result := TEncoding.UTF8.GetString(LBytes);
finally
LStream.Free;
end;
end;
function HumanSize(ABytes: Int64): string;
begin
if ABytes >= 1024 * 1024 then
Result := Format('%.1f MB', [ABytes / (1024 * 1024)])
else if ABytes >= 1024 then
Result := Format('%.0f KB', [ABytes / 1024])
else
Result := Format('%d B', [ABytes]);
end;
/// Premiere lettre du nom affiche, pour la pastille sans icone.
function Initial(const AName: string): string;
var
LCh: Char;
begin
Result := '?';
for LCh in AName do
if LCh.IsLetterOrDigit then Exit(LCh.ToUpper);
end;
function VaultToJson(AVault: TVault): string;
var
LRoot: TJSONObject;
LEntries, LFolders: TJSONArray;
LObj: TJSONObject;
LIdx: TArray<Integer>;
I: Integer;
E: TVaultEntry;
LFolder: string;
begin
LRoot := TJSONObject.Create;
try
LEntries := TJSONArray.Create;
LRoot.AddPair('entries', LEntries);
// Search('') rend les vivantes, deja triees par nom affiche. La page
// conserve cet ordre : trier de nouveau en JS demanderait de repliquer
// la meme convention de comparaison pour rien.
LIdx := AVault.Search('');
for I := 0 to High(LIdx) do
begin
E := AVault.Entry(LIdx[I]);
LObj := TJSONObject.Create;
LObj.AddPair('i', TJSONNumber.Create(LIdx[I]));
LObj.AddPair('name', E.Display);
// Le site s'il existe, sinon l'identifiant : deux fois la meme chaine
// sur deux lignes n'apprendrait rien.
if E.Site <> '' then LObj.AddPair('sub', E.Site)
else if E.Username <> E.Display then LObj.AddPair('sub', E.Username)
else LObj.AddPair('sub', '');
LObj.AddPair('folder', E.Folder);
// Corpus de recherche pre-assemble et en minuscules : la page cherche a
// chaque frappe, autant ne pas recomposer la meme chaine a chaque fois.
// Il couvre ce que couvrait TVault.Search - nom, site, identifiant,
// etiquettes - donc l'identifiant reste trouvable meme quand la ligne
// affiche le site.
LObj.AddPair('s', (E.Title + ' ' + E.Site + ' ' + E.Username + ' ' +
E.Tags).ToLower);
LObj.AddPair('icon', E.IconB64);
LObj.AddPair('initial', Initial(E.Display));
LObj.AddPair('hasUser', TJSONBool.Create(E.Username <> ''));
LObj.AddPair('isNote', TJSONBool.Create(E.IsNote));
LEntries.AddElement(LObj);
end;
LFolders := TJSONArray.Create;
LRoot.AddPair('folders', LFolders);
for LFolder in AVault.Folders do
LFolders.Add(LFolder);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
/// Les lignes d'une entree. UN SEUL ENDROIT decide de leur composition et de
/// leur ordre : DetailToJson et DetailValues en dependent tous les deux, et
/// un ecart entre eux ferait copier la valeur de la ligne voisine.
type
TDetailRow = record
Caption, Value: string;
Secret: Boolean;
/// LE NOM DU CHAMP DANS L'INSTANTANE, vide si la ligne ne se modifie pas.
///
/// Il porte les deux choses a la fois : qu'on puisse editer, et quoi
/// editer. Marque ICI plutot que devine du libelle cote page : un intitule
/// est de la prose, et le jour ou il change la page enverrait un nom de
/// champ inexistant - ou, pire, cesserait silencieusement de proposer la
/// modification.
///
/// Supprimer une entree n'y figure pas : cela demande des tombstones, et
/// c'est l'etape 3 de la §7.
Field: string;
/// Rang de la piece jointe dans l'entree, ou -1. Il ne se deduit pas du
/// rang de la ligne : les champs personnalises s'intercalent avant.
AttachIndex: Integer;
/// La valeur tient sur plusieurs lignes. Marque ICI, pas devine du libelle
/// ni du contenu : un <input> APPLIQUE la sanitisation de HTML et retire
/// les retours a la ligne en silence. Une note modifiee depuis le telephone
/// y perdait sa mise en forme, et la perte partait au depot.
Multiline: Boolean;
end;
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
var
LRows: TArray<TDetailRow>;
I: Integer;
procedure Add(const ACaption, AValue: string; ASecret: Boolean;
const AField: string = ''; AAttach: Integer = -1;
AMultiline: Boolean = False);
begin
// Une ligne vide n'apprend rien - SAUF si elle se remplit. Un identifiant
// absent qu'on ne peut pas ajouter serait une impasse.
if (AValue = '') and (AField = '') then Exit;
SetLength(LRows, Length(LRows) + 1);
LRows[High(LRows)].Caption := ACaption;
LRows[High(LRows)].Value := AValue;
LRows[High(LRows)].Secret := ASecret;
LRows[High(LRows)].Field := AField;
LRows[High(LRows)].AttachIndex := AAttach;
LRows[High(LRows)].Multiline := AMultiline;
end;
begin
Add('Name', E.Title, False, 'title');
// Le corps d'une note est range dans le champ mot de passe : meme donnee,
// autre intitule, et pas de raison de la masquer comme un secret.
//
// Un site et un identifiant n'ont aucun sens pour une note. Les proposer
// vides reviendrait a inviter a remplir ce que rien n'affichera jamais.
if E.IsNote then
Add('Content', E.Password, False, 'password', -1, True)
else
begin
Add('Site', E.Site, False, 'site');
Add('Username', E.Username, False, 'username');
Add('Password', E.Password, True, 'password');
// LE SECRET, pas le code. Le code a sa propre case en haut, calculee et
// renouvelee toutes les trente secondes ; c'est le secret qui se pose, se
// change et se retire, et sans cette ligne il n'y avait aucun moyen de le
// faire depuis le telephone. Masque comme un mot de passe : il vaut le
// second facteur en entier.
Add('2FA secret', E.Totp, True, 'totp_secret');
end;
for I := 0 to High(E.Fields) do
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
// Les pieces jointes ne sont pas encore enregistrables sur le telephone,
// mais taire leur existence serait pire : on saurait sur le poste qu'une
// entree porte un document, et pas ici.
for I := 0 to High(E.Attachments) do
Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes),
E.Attachments[I].FileName, False, '', I);
// DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil.
// Le dossier est une chaine simple, choisie parmi celles qui existent - la
// page en fait une liste plutot qu'un champ libre, sinon une faute de frappe
// rangerait l'entree dans un dossier que rien n'affiche.
// Les etiquettes, elles, sont un TABLEAU dans l'instantane : les ecrire comme
// une chaine casserait leur forme pour le poste. Lecture seule tant que le
// patch ne sait pas ecrire un tableau.
Add('Folder', E.Folder, False, 'folder');
Add('Tags', E.Tags, False);
Add('Type', E.Template, False);
// Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater
// demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
Add('Updated', E.UpdatedAt, False);
Result := LRows;
end;
function DetailToJson(const AEntry: TVaultEntry): string;
var
LRoot: TJSONObject;
LArr: TJSONArray;
LObj: TJSONObject;
LRows: TArray<TDetailRow>;
I: Integer;
begin
LRows := BuildRows(AEntry);
LRoot := TJSONObject.Create;
try
LRoot.AddPair('title', AEntry.Display);
LRoot.AddPair('totp', TJSONBool.Create(AEntry.Totp <> ''));
LArr := TJSONArray.Create;
LRoot.AddPair('rows', LArr);
for I := 0 to High(LRows) do
begin
LObj := TJSONObject.Create;
LObj.AddPair('c', LRows[I].Caption);
LObj.AddPair('v', LRows[I].Value);
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
// 'e' porte le nom du champ, et sa presence vaut "modifiable".
if LRows[I].Field <> '' then LObj.AddPair('e', LRows[I].Field);
// 'a' porte le rang de la piece jointe : sa presence vaut
// "enregistrable", et la page y met le bouton de telechargement.
if LRows[I].AttachIndex >= 0 then
LObj.AddPair('a', TJSONNumber.Create(LRows[I].AttachIndex));
if LRows[I].Multiline then LObj.AddPair('m', TJSONBool.Create(True));
LArr.AddElement(LObj);
end;
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
out ACaption, AValue: string): Boolean;
var
LAttach: Integer;
begin
Result := DetailRowAt(AEntry, AIndex, ACaption, AValue, LAttach);
end;
function DetailRowAt(const AEntry: TVaultEntry; AIndex: Integer;
out ACaption, AValue: string; out AAttachIndex: Integer): Boolean;
var
LRows: TArray<TDetailRow>;
begin
AAttachIndex := -1;
LRows := BuildRows(AEntry);
Result := (AIndex >= 0) and (AIndex <= High(LRows));
if not Result then Exit;
ACaption := LRows[AIndex].Caption;
AValue := LRows[AIndex].Value;
AAttachIndex := LRows[AIndex].AttachIndex;
end;
end.
+325
View File
@@ -0,0 +1,325 @@
# 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) |
| `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) :
```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
```
> **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.
+356
View File
@@ -0,0 +1,356 @@
# Client Android — relecture et scénario d'essai
Réécrit le **2026-08-28**, après une séance qui a remué les deux secrets du
coffre, l'écran des réglages, le code de déverrouillage et le clavier. La
version précédente décrivait une application qui n'existe plus.
Ce que cette séance a confirmé, et qui vaut plus que n'importe quelle liste :
**les défauts se tiennent par la main.** Corriger le clavier qui volait le
premier appui a laissé le clavier ouvert ; le clavier ouvert recouvrait le
message ; et le message invisible cachait qu'une action avait réussi. Aucun des
deux derniers n'était observable tant que le premier tenait. D'où la règle qui
suit : **quand un essai échoue, lire le journal AVANT de retenter**, parce que la
synchronisation le vide.
---
## Ce que la séance a corrigé, et pourquoi c'est éprouvable
### 1. Le journal ne perdait plus les causes — CORRIGÉ, et c'est le plus important
Trois trous, tous du même genre : une faute arrivait, et rien ne la nommait.
- **Les gestionnaires d'erreur lisaient l'exception après sa libération.**
`RegisterLocal` et `LoginLocal` mettaient leur handler en file avec
`TThread.Queue`, puis y lisaient `E.Message` — or Delphi libère `E` en sortant
du `except`, bien avant que la file s'exécute. Résultat : un message vide, et
parfois une violation d'accès. **Le vrai motif n'a jamais été affiché de toute
l'histoire de ce chemin.**
- **Le dispatch du pont n'était gardé par rien** : une faute dans un handler
remontait à FMX, qui affichait « Access violation » avec une adresse pendant
que le journal montrait la commande reçue suivie de rien.
- **Le retour sur le thread principal** (`TThread.Queue`) n'était couvert ni par
le `except` du thread, ni par celui du pont : il s'exécute après que les deux
ont rendu la main.
**Ce que ça change pour les essais** : un échec porte désormais un nom. Si un
essai ci-dessous échoue sans laisser de ligne au journal, c'est un quatrième
trou du même genre, et il faut le traiter avant de chercher le défaut.
### 2. Deux secrets, longtemps confondus — CORRIGÉ
Le mot de passe de création scellait le conteneur local **et** prétendait
sceller l'instantané du dépôt. Un coffre créé ici publiait donc sous son propre
mot de passe, que le poste ne savait plus ouvrir.
- `FVaultPwd` ouvre `vault-local.json`, et rien d'autre.
- Le secret du dépôt vit dans les réglages, doit être identique sur tous les
appareils, et lui seul ouvre et rescelle l'instantané.
- **Ils ne se rencontrent qu'à un endroit**, la réconciliation : le conteneur
local s'ouvre sous le sien, puis la clé est jetée avant qu'on touche au dépôt.
Migration : rien à rechiffrer. Les coffres existants ont simplement les deux
valeurs égales, et cessent d'être forcés de le rester.
### 3. « Coffre ouvert » se lisait sur le nombre d'entrées — CORRIGÉ
Un coffre **vide** en compte zéro, comme un coffre fermé. Le garde de `DoSync`
restait donc vrai après l'ouverture, réarmait la reprise, et l'on tournait en
rond. Le même idiome coupait `OnIdleTimer` : un coffre vide ne s'auto-verrouillait
jamais. Les trois lisent `FPlain`, le clair en main.
### 4. Ce qui reste assumé, sans correction
- **`SaveAttachment`, `RegisterLocal` et `LoginLocal`** libèrent l'indicateur
d'occupation dans leur `except` plutôt que dans un `finally`. Le minuteur de
25 s le lève de toute façon, et c'est son rôle.
- **`ParseJSONValue(...) as TJSONObject`** fuit sur une entrée qui n'est pas un
objet — le transtypage lève avant l'affectation. N'arrive que sur un conteneur
corrompu, où l'on a de plus gros ennuis.
- **Le nom d'utilisateur ne sert à rien dans l'APK.** Il est écrit dans
l'instantané à la création, et c'est le **poste** qui s'en sert pour refuser de
fondre deux coffres différents. À la connexion il ne protège rien : le mot de
passe seul ouvre, et le vrai nom est lu dans le conteneur.
---
## Scénario d'essai
Chaque étape dit **ce qu'il faut voir**, pas seulement ce qu'il faut faire : un
test dont on ne sait pas ce qu'il doit produire ne prouve rien.
**L'ordre n'est pas alphabétique** : `Préparation → C → A → B → D → E → …`. La
série C réconcilie, et c'est elle qui fait du coffre né ici un coffre **publié**.
Tant qu'il ne l'est pas, `CommitChange` voit `LLocalOnly`, écrit *« save: written
locally »* et ne publie rien — il n'y a pas de dépôt. La série A guetterait donc
un point orange qui ne peut pas paraître, et conclurait à un défaut là où il n'y
a que l'ordre.
**Outil de remise à zéro** : *Settings → Debug → Delete the local vault* (deux
appuis). Il efface cache, conteneur local et drapeau d'attente, **et garde les
réglages du dépôt** — on retélécharge sans rien retaper. Absent en release : la
commande n'y est même pas compilée.
### Préparation — l'état de départ, et il compte
Plusieurs séries agissent sur une entrée existante. Partir d'un coffre vide les
rendrait ininterprétables : on ne sait pas distinguer « la modification n'a pas
voyagé » de « il n'y avait rien à faire voyager ».
1. *Settings → Debug → Delete the local vault*, puis **Create vault**. Le coffre
naît vide : zéro entrée, **et aucun dossier** — `NewEmptySnapshot` écrit un
tableau `folders` vide.
2. Créer **une seule entrée**, avec un mot de passe reconnaissable. Elle se range
dans **`All`**, et c'est ce premier enregistrement qui fait apparaître le
dossier : `AddEntry` pose `folder = 'All'` parce que c'est le dossier par
défaut du poste, pas parce que c'est joli.
3. Vérifier qu'il n'y a **qu'un dossier** dans la barre. Deux au départ voudrait
dire qu'un instantané traîne encore.
C'est le point de départ de tout ce qui suit. Y revenir entre deux séries qui se
gênent coûte deux appuis.
**Un coffre vide reste un cas à éprouver**, mais explicitement : c'est lui qui a
révélé que « coffre ouvert » se lisait sur le nombre d'entrées (défaut n° 3), et
que l'auto-verrouillage se coupait de lui-même. À reprendre au moins une fois
sans entrée : *Sync* ne doit pas boucler, et le coffre doit finir par se
verrouiller seul.
### A. Enregistrement hors connexion — le plus important
**Exige un coffre PUBLIÉ** — donc la série C d'abord. Sur un coffre né ici et
jamais réconcilié, il n'y a rien à publier et rien ne se met en attente.
1. Coffre ouvert, **couper les données** (mode avion).
2. Modifier un mot de passe → *« saved here, not published yet »*, et **point
orange** sur le cadenas.
3. Verrouiller → le point doit être **sur le bouton Sync** de l'accueil.
4. **Fermer complètement l'application** (glisser depuis les récentes), rouvrir,
ouvrir le coffre → le point doit **être encore là**. C'est l'étape qui éprouve
la persistance du drapeau ; sans elle, le reste ne prouve rien.
5. Rétablir le réseau, *Sync* → *« Offline changes published »*, point éteint.
6. Sur le poste, synchroniser → la modification doit y être.
### B. La fusion, qui est le cœur du sujet
1. Téléphone **hors ligne**, modifier l'entrée **X**.
2. Sur le poste, modifier une **autre** entrée **Y**, synchroniser.
3. Téléphone en ligne, *Sync*.
4. **Les deux modifications doivent survivre.** Si une seule survit, c'est
l'arbitrage sur `updated_at` qu'il faut regarder.
5. Variante avec un **dossier créé hors connexion** : il doit apparaître dans la
barre de dossiers après la fusion, et sur le poste après sa synchronisation.
### C. Les deux secrets — nouveau, et jamais éprouvé de bout en bout
1. Effacer le coffre local (bouton Debug). Les trois champs du dépôt doivent
**rester remplis** — c'est la moitié de ce que ce bouton promet.
2. *Create vault* avec un mot de passe **différent** de celui du dépôt.
3. Ouvrir les réglages : le champ *sync password* doit porter **celui du dépôt**,
pas celui qu'on vient de choisir. C'est toute la séparation en une capture.
4. *Sync* → la réconciliation lit le dépôt et republie **sous le secret du
dépôt**. Contre-épreuve décisive : **le poste doit relire le dépôt sans se
plaindre.** S'il échoue, c'est que la clé du conteneur local a servi à
resceller l'instantané.
5. Après la réconciliation, `vault-local.json` doit avoir disparu — c'est ce
geste qui passe l'autorité au dépôt.
### D. Ouvrir demande quelque chose — nouveau
1. Effacer le coffre, puis *Download vault*.
2. **Fermer complètement l'application**, rouvrir → le champ de code doit
paraître et réclamer **le mot de passe de sync**. Le cache est scellé sous
lui, parce qu'il le contient déjà : l'ouvrir sans rien livrait le coffre *et*
le secret du dépôt.
3. Vérification par le fichier, si adb est à portée :
`run-as com.embarcadero.pmserverApk od -An -tu1 -N1 files/vault.bin` doit
rendre **4** (`VER_PIN`) et non 1.
4. Un **mauvais code** → *« Wrong unlock code »* **en rouge**, et *Open vault*
reste offert : le cache n'est pas déclaré illisible.
5. Sur un appareil **avec biométrie**, rien de tout cela : `VER_WRAP` garde déjà
la porte, et imposer une frappe serait un recul. C'est volontaire.
### E. Le dépôt a bougé — témoin bleu
**Cette série était impossible telle qu'elle était écrite**, et la mesure le dit :
`AUTOLOCK_MINUTES = 3` verrouille après trois minutes d'inactivité, tandis que
`PROBE_EVERY_TICKS = 40` — à quinze secondes le battement — demande **dix minutes
de coffre ouvert**. Le compteur n'avance que tant que le coffre l'est, et
`LockVault` sort avant de l'incrémenter. « Laisser ouvert dix minutes sans y
toucher » verrouillait donc à la troisième, et le sondage ne partait jamais.
Ce n'est pas un défaut de l'application : le témoin sert **pendant qu'on se sert
du coffre**, et quelqu'un qui s'en sert le touche. Mais il faut le dire, sinon on
cherche un point qui ne peut pas paraître.
1. Coffre ouvert sur le téléphone. **Le toucher toutes les deux minutes** —
faire défiler la liste suffit, il ne faut surtout pas synchroniser : un
*Sync*, comme une ouverture, remet le compteur à zéro.
2. Sur le poste, modifier une entrée et synchroniser.
3. Continuer à toucher jusqu'à **dix minutes de coffre ouvert**. Le journal doit
dire `remote has moved - sync to bring it in`, et un **point bleu** paraître
sur *Sync* et sur le cadenas.
4. **Rien ne doit changer à l'écran de soi-même.** Cette condition compte autant
que le témoin : si l'entrée qu'on regarde se met à jour toute seule, le
sondage adopte au lieu de signaler, ce qui est plus grave qu'un témoin absent.
5. *Sync* → le point bleu s'éteint.
6. Les deux ensemble : provoquer A puis E sans synchroniser → **orange et bleu
côte à côte**. Ils demandent deux gestes différents.
**Si tu préfères ne pas veiller dix minutes**, la contre-épreuve courte : mets
`PROBE_EVERY_TICKS` à 8 — deux minutes — recompile, éprouve les points 3 à 6,
puis remets 40. C'est la seule série du plan où le réglage de production rend
l'observation coûteuse.
### F. Réglages — largement réécrits, à reprendre en entier
1. **Coffre fermé**, ouvrir les réglages depuis l'accueil → la section *Unlock
code* doit être **absente**. Poser comme retirer exigent le clair en main :
coffre fermé, les deux ne pourraient que refuser.
2. Ouvrir le coffre, roue dentée → la section paraît.
3. **Poser un code** : taper, *Set this code* → *« Unlock code set »*
**immédiatement, sans quitter l'écran**. Plus de « ouvre le coffre pour
l'appliquer » : le code s'applique sur-le-champ.
4. Le message doit se voir **au-dessus du clavier resté ouvert**. C'est le
défaut le plus discret de la séance : le toast vivait sous le clavier.
5. **Un seul appui** doit suffire sur *Set this code*. S'il en faut deux, le
premier est mangé par le clavier et la correction du focus a régressé.
6. **Retirer le code** : *Remove the unlock code* sans rien taper → refus
*« Type the current unlock code to remove it »*. Puis taper le code courant,
deux appuis → *« Unlock code removed »*. Fermer l'application, rouvrir → **le
code n'est plus demandé**.
7. Contre-épreuve : *Set this code* avec un champ **vide** ne doit **pas** retirer
le code — c'était une porte dérobée vers le retrait sans le connaître.
8. Le champ *sync password* doit porter le mot de passe du coffre **détenu**, pas
une valeur de premier lancement.
9. Touche **retour** dans les réglages → referme **sans** verrouiller ni changer
de vue.
### G. Icônes
1. Ouvrir une entrée dont le *Site* est une adresse — `git.ai-agents4you.com`.
2. Trois points → **Fetch icon** → l'icône doit apparaître dans la liste.
3. Une entrée dont le site n'est **pas** une adresse (*Gitea*, ou vide) → refus
*« no web address to look up »*. Sans point dans le nom, il n'y a rien à
demander au relais.
4. Un domaine à trois niveaux (`chat.qwen.ai`) doit retomber sur le second
niveau : c'est le deuxième essai.
5. **Remove icon** → l'entrée reprend son initiale.
6. Synchroniser depuis le poste : l'icône doit y arriver, et le poste ne doit
**pas** la traiter comme une icône choisie — c'est le rôle du drapeau de
provenance, mis à zéro par une recherche automatique.
### H. Anciens mots de passe
**Le poste doit synchroniser d'abord** : il ne les envoie que depuis peu, et un
instantané publié avant ne porte aucun historique.
1. Sur le poste, changer le mot de passe d'une entrée, puis synchroniser.
2. Téléphone : *Sync*, ouvrir l'entrée, trois points → **Previous passwords**.
L'ancienne version doit être là, avec sa date.
3. Copier l'une d'elles → *« Previous password copied »*.
4. Touche **retour** → le voile se referme sans toucher au coffre.
5. Une entrée jamais modifiée → *« No previous password recorded »*, et non un
écran vide.
6. Contre-épreuve : verrouiller, rouvrir, **Previous passwords** → la liste doit
être reconstruite, jamais celle d'avant restée à l'écran.
### I. Clavier, mise en page, retour d'application — nouveau
1. Taper dans un champ, appuyer sur un bouton **hors de sa rangée** (*Open
vault*, *Sign in*, *Set this code*) → **un seul appui**, et le clavier se
replie ensuite.
2. Taper dans un champ, appuyer sur un bouton **de sa rangée** (l'œil d'un champ
secret) → le clavier **reste ouvert** et le curseur en place. Les deux moitiés
comptent : la première sans la seconde laisse le clavier dressé.
3. **Réglages ouverts coffre FERMÉ**, verrouiller le téléphone, déverrouiller →
la page doit revenir. C'était un aplat bleu sans rien dessus : le masquage ne
demandait rien, la restitution exigeait un coffre ouvert.
4. Même chose **coffre ouvert** : passé le délai de grâce, le verrouillage doit
avoir lieu et ramener sur l'accueil — pas sur la liste.
5. Accueil sans coffre : l'ordre doit être **Download vault → Settings →
username / password → Create vault**, espacements réguliers, et *Download
vault* doit porter la couleur d'accent quand un dépôt est configuré.
6. Les boutons du bandeau (roue, +, cadenas) et ceux de la barre de sélection
sont **ronds**.
### J. Sélection et actions de liste
1. Appui long → barre contextuelle, compteur.
2. Deuxième ligne → compteur à 2.
3. **Dupliquer** → deux copies, suffixées *(copy)*.
4. **Supprimer** → confirmation en deux temps, puis les lignes disparaissent.
5. Touche **retour** pendant une sélection → elle s'annule **sans verrouiller**.
6. Sur le poste, synchroniser → les suppressions doivent y être répercutées.
### K. Notes multilignes
1. **+** → *Note* → zone de texte pleine largeur, *Site* et *Username* disparus.
2. Taper plusieurs lignes — Entrée doit **insérer un saut**, pas replier le
clavier.
3. Enregistrer, rouvrir → les ruptures doivent être là **en lecture**.
4. Basculer *Login* → *Note* après avoir tapé : la saisie doit **survivre**.
5. Contre-épreuve : dans un champ d'une ligne, Entrée doit toujours **replier le
clavier**.
### L. Pièces jointes
1. Ligne *Attachment* → flèche de téléchargement → le fichier apparaît dans
*Téléchargements* sous son nom, accents et arabe compris.
### M. Empaquetage — CLOSE le 2026-08-29, apres quatre obstacles
L'APK signe existe : `CN=R Zakarya, ..., C=DZ`, 12,1 Mo. Ce chemin n'avait
jamais tourne, et il a butte quatre fois — toutes sur des choses que **seule une
release partant d'un terrain vierge pouvait rencontrer**. Le debogage vit sur un
arbre constitue au fil des mois, ou tout cela existait deja.
1. **Le dossier de mise en scene n'existait pas.** Les trois copies du script s'y
deversent avant l'etape Deploy qui le creuse. Corrige : on le cree d'abord.
2. **Le volume etait plein.** Debug et Release cote a cote ne tiennent pas sur
ce disque ; le `strip` echouait a mi-fichier et empoisonnait tout ce qui
suivait. A surveiller avant chaque release.
3. **aapt refusait cinq doublons a la racine de `res\`**, deposes par le
deploiement de Delphi alors que les vrais sont dans `drawable\`. Corrige :
on les ecarte avant l'appel.
4. **Les ressources generees n'etaient pas mises en place.** Le Build les depose
a plat dans `Android64\<Config>\` et le Deploy ne les range dans `res\`
qu'en DEBUG — d'ou `@style/AppTheme` introuvable. Corrige : le script les
range lui-meme, avant les notres pour que notre `colors.xml` garde le dernier
mot.
**Ce qu'il faut verifier a chaque release :**
1. La sortie doit afficher `Signer #1 certificate DN: CN=R Zakarya`. Si elle dit
`CN=Android Debug`, la release ne vaut rien.
2. Elle ne s'installe pas d'elle-meme, et c'est voulu : une signature differente
obligerait a desinstaller d'abord, ce qui effacerait le coffre local.
3. La section **Debug** ne doit pas etre atteignable. **Mesure exacte** : la
chaine `DEBUG: local vault dropped` doit etre absente du `.so` — en UTF-16LE,
comme toutes les chaines Delphi ; la chercher en ASCII ne prouve rien.
Attention, `wipelocal` et `showDebug` s'y trouvent dans les DEUX
configurations : ils viennent de la page HTML embarquee, qui porte toujours
le bouton. Ce qui manque en release, c'est l'appel `PM.showDebug()` et le
gestionnaire du pont — la section est donc inatteignable et sans effet, pas
absente.
4. **Le mot de passe du magasin ne se retrouve pas.** Un JKS ne s'ouvre pas sans
lui. Aujourd'hui le recreer ne coute rien ; une fois une version distribuee,
le perdre voudra dire ne plus jamais pouvoir mettre a jour l'application sur
ces appareils - Android refuse une mise a jour dont la signature a change.
---
## Ce qui n'est toujours pas éprouvé
- **Le chemin `-Release`** du script n'a jamais produit d'APK signé. Il compile,
c'est tout ce qu'on sait.
- **L'invalidation de la clé par le système** (§ D.5 pour la biométrie) : le
message *« Device protection changed »* existe et son drapeau est posé là où
l'invalidation se constate, mais personne ne l'a vu s'afficher. Il faudrait
retirer le verrouillage d'écran d'un appareil qui en a un.
- **`MergeSelfCheck`** tourne au démarrage en débogage et écrit `merge selfcheck:
the four rules hold` dans le journal. Il passe ; personne ne l'a lu en
cherchant à le mettre en défaut.
- **Le mode `login`** (conteneur local sans cache) ne s'atteint qu'en supprimant
le cache à la main. C'est là qu'a été trouvée la lecture après libération : il
n'avait jamais tourné.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fond de l'icone adaptative : un aplat, dans le bleu de l'application.
Le systeme le decoupe a la forme voulue par le lanceur (cercle, carre
arrondi, goutte), donc il doit couvrir les 108 unites entieres.
Pas de degrade : aapt - la premiere version, celle que sert la chaine de
ce projet - ne connait pas les degrades en ligne dans un vecteur. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#1B3563"
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
</vector>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Avant-plan de l'icone adaptative : un bouclier a serrure.
TOUT DOIT TENIR DANS LES 66 UNITES CENTRALES (soit 21..87) : le systeme
recadre et anime la couche autour de cette zone sure, et ce qui deborde
peut etre rogne selon le lanceur. Le dessin occupe 31..77 en x, 29..82
en y.
UN SEUL CHEMIN, EN EVENODD. La serrure n'est pas peinte de la couleur du
fond, elle est EVIDEE : le fond se voit au travers. Peinte, elle imposerait
de tenir deux fichiers d'accord sur une couleur, et le moindre ecart se
verrait comme une tache. Evidee, il n'y a rien a synchroniser.
Blanc plein plutot qu'un trait bleu : a 36 px un contour se boue, une
silhouette pleine tient. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M54,29 L77,37 L77,56 C77,69.5 67,78 54,82 C41,78 31,69.5 31,56 L31,37 Z
M54,50 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0 Z
M51,55.5 L57,55.5 L58.6,70 L49.4,70 Z" />
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Couche monochrome (icones thematiques d'Android 13+). Le systeme ne
retient que la FORME : il repeint le dessin avec la couleur du theme de
l'utilisateur, donc la couleur posee ici n'a aucune importance.
Meme bouclier que l'avant-plan, serrure evidee de la meme facon. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M54,29 L77,37 L77,56 C77,69.5 67,78 54,82 C41,78 31,69.5 31,56 L31,37 Z
M54,50 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0 Z
M51,55.5 L57,55.5 L58.6,70 L49.4,70 Z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Ecran de demarrage pour Android 11 et anterieurs, qui n'ont pas l'API
SplashScreen : le systeme se contente d'afficher windowBackground, donc
c'est ici qu'il faut composer fond + logo.
Remplace la version generee, qui centrait une image bitmap de Delphi. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/splash_background" />
<item
android:gravity="center"
android:width="120dp"
android:height="120dp"
android:drawable="@drawable/splash_vector" />
</layer-list>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Icone de l'ecran de demarrage.
Sur Android 12+, le systeme l'affiche via windowSplashScreenAnimatedIcon
dans un disque de 288 unites dont il ne garantit que les 192 centrales :
le dessin occupe 103..186, bien a l'interieur.
Meme bouclier que l'icone du lanceur, a l'echelle.
ICI LA SERRURE EST PEINTE, pas evidee : la couche de demarrage est posee
a plat sur le fond du systeme, et un evidement y ferait un trou. Elle est
entierement contenue dans le bouclier blanc, donc sa couleur n'a qu'a
trancher sur du blanc - elle ne depend d'aucun autre fichier. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="288dp"
android:height="288dp"
android:viewportWidth="288"
android:viewportHeight="288">
<path
android:fillColor="#FFFFFF"
android:pathData="M144,96.3 L185.4,110.7 L185.4,144.9 C185.4,169.2 167.4,184.5 144,191.7 C120.6,184.5 102.6,169.2 102.6,144.9 L102.6,110.7 Z" />
<path
android:fillColor="#1B3563"
android:pathData="M144,134.1 m-12.6,0 a12.6,12.6 0 1,0 25.2,0 a12.6,12.6 0 1,0 -25.2,0 Z" />
<path
android:fillColor="#1B3563"
android:pathData="M138.6,144 L149.4,144 L152.3,170.1 L135.7,170.1 Z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Remplace le colors.xml genere, dont splash_background vaut #FFF : un
ecran de demarrage blanc suivi d'une application sombre donne un flash
desagreable a chaque lancement. -->
<resources xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Le haut du degrade de l'application : l'ecran de demarrage se fond
alors dans le premier ecran au lieu de le trancher. -->
<color name="splash_background">#3E6394</color>
</resources>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Le trafic en clair n'est permis que vers la BOUCLE LOCALE, et interdit
partout ailleurs.
Il l'etait auparavant partout, via usesCleartextTraffic="true". C'etait pour
une seule raison : l'essai local passe par "adb reverse", qui fait voir le
poste comme 127.0.0.1 et parle HTTP. Mais l'attribut ne distingue pas la
boucle locale du reste du monde - une URL mal saisie, ou un serveur repassant
en HTTP, envoyait le jeton en clair sur le reseau sans que rien ne le signale.
Ici la permission est nominative. Le jeton ne peut plus sortir qu'en TLS, et
une URL "http://" vers un vrai hote echoue tout de suite, ce qui est
exactement le comportement voulu : bruyant, pas silencieux.
cleartextTrafficPermitted="false" a la racine n'est pas redondant avec le
defaut d'Android 9+ : il vaut aussi pour les versions anterieures, et il dit
l'intention noir sur blanc.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>
+120
View File
@@ -0,0 +1,120 @@
# Compile, empaquette, installe et lance le client Android.
#
# POURQUOI CE SCRIPT. La chaine a quatre etapes qui ne s'appellent pas entre
# elles, et en sauter une donne un APK qui s'installe sans porter la
# modification - le symptome se lit alors comme un bug applicatif. La premiere,
# web.res, s'est deja fait oublier une fois : la page embarquee etait celle
# d'avant l'edition, et le pont echouait pour une raison qui n'existait plus.
#
# -NoInstall compile et empaquette sans toucher au telephone.
# -Release configuration Release, signee avec VOTRE magasin de cles.
#
# LA RELEASE NE S'INSTALLE PAS PAR-DESSUS LE DEBOGAGE. Android refuse une
# signature differente sur un paquet deja installe ; il faut desinstaller
# d'abord, ce qui EFFACE le cache local et les reglages de l'appareil. Le
# script ne le fait donc pas a votre place - il produit l'APK et s'arrete.
#
# .\android-client\build-apk.ps1 -Release `
# -Keystore C:\cles\pmserver-release.jks -Alias pmserver
param(
[switch]$NoInstall,
[switch]$Release,
[string]$Keystore,
[string]$Alias = 'pmserver'
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$bin = 'C:\Program Files (x86)\Embarcadero\Studio\23.0\bin'
$sdk = 'C:\Users\Public\Documents\Embarcadero\Studio\23.0\CatalogRepository'
$adb = "$sdk\AndroidSDK-2525-23.0.51511.6924\platform-tools\Adb.exe"
$strip = "$sdk\AndroidNDK-21-23.0.51511.6924\android-ndk-r21\toolchains\" +
'aarch64-linux-android-4.9\prebuilt\windows-x86_64\aarch64-linux-android\bin\strip.exe'
$cfg = if ($Release) { 'Release' } else { 'Debug' }
$out = "$root\Android64\$cfg"
$stage = "$out\pmserverApk"
if ($Release) {
if (-not $Keystore) { throw 'Chemin du magasin de cles requis : -Keystore' }
if (-not (Test-Path $Keystore)) { throw "Magasin introuvable : $Keystore" }
# LU ICI ET NULLE PART AILLEURS. Read-Host -AsSecureString n'affiche rien et
# ne laisse pas la frappe dans l'historique de la console.
$sec = Read-Host "Mot de passe du magasin $([IO.Path]::GetFileName($Keystore))" -AsSecureString
$env:PM_KS_PASS = [Runtime.InteropServices.Marshal]::PtrToStringBSTR(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
# 1. La page HTML -> ressource liee dans l'executable.
Push-Location "$PSScriptRoot\web"
& "$bin\brcc32.exe" web.rc -foweb.res | Out-Null
Pop-Location
# 2. Compilation Delphi.
$msbuild = "`"$bin\rsvars.bat`" && msbuild `"$root\pmserverApk.dproj`""
$log = cmd /c "$msbuild /p:Platform=Android64 /p:Config=$cfg /t:Build /v:minimal /nologo" 2>&1
$err = $log | Select-String -Pattern 'error'
if ($err) { $err | ForEach-Object { $_.Line }; throw 'compilation echouee' }
$log | Select-String -Pattern 'lines,' | ForEach-Object { $_.Line.Trim() }
# 3. Mise en place a la main de ce que le deploiement ne place pas : manifeste,
# .dex, et la bibliotheque native ALLEGEE - la version de debogage pese
# 91 Mo, plus que la place libre sur ce volume.
#
# LES DOSSIERS D'ABORD, ET C'EST LA PREMIERE RELEASE QUI L'A APPRIS. Cet arbre
# est normalement creuse par l'etape Deploy - qui vient APRES celle-ci. En
# debogage il existait deja, laisse par un deploiement anterieur, et personne ne
# s'en apercevait ; pour une configuration jamais empaquetee, les trois copies
# ci-dessous tombaient sur un chemin absent. Les creer ne coute rien quand elles
# existent, et evite qu'une configuration neuve echoue sur sa premiere ligne.
New-Item -ItemType Directory -Force -Path "$stage",
"$stage\classes", "$stage\library\lib\arm64-v8a" | Out-Null
Copy-Item "$out\AndroidManifest.xml" "$stage\AndroidManifest.xml" -Force
Copy-Item "$out\pmserverApk.classes\*.dex" "$stage\classes\" -Force
& $strip --strip-debug -o "$stage\library\lib\arm64-v8a\libpmserverApk.so" "$out\libpmserverApk.so"
# L'AAPT DE DEPLOY ECHOUE, ET C'EST ATTENDU. Le manifeste reference
# @xml/network_security_config, que la generation de ressources de Delphi ne
# contient pas ; poser le fichier avant Deploy ne sert a rien, Deploy reconstruit
# res\ juste avant d'appeler aapt. L'etape suivante repose nos ressources et
# refait l'archive entiere, donc l'APK livre est complet - verifie a
# "aapt list" : res/xml/network_security_config.xml y est, et l'attribut du
# manifeste resout vers un identifiant reel.
#
# On ne masque donc que CETTE erreur, nommement, et tout autre message remonte.
$deploy = cmd /c "$msbuild /p:Platform=Android64 /p:Config=$cfg /t:Deploy /v:minimal /nologo" 2>&1
$deploy | Select-String -Pattern 'error' |
Where-Object { $_.Line -notmatch 'network_security_config|Unable to execute.*Aapt\.exe' } |
ForEach-Object { $_.Line }
# 4. aapt + zipalign + apksigner, et remise en place de ce que Deploy efface.
try {
if ($Release) {
& "$PSScriptRoot\repack-apk.ps1" -Config Release -Keystore $Keystore -Alias $Alias
} else {
& "$PSScriptRoot\repack-apk.ps1" | Select-Object -Last 1
}
} finally {
# Le mot de passe ne survit pas au script, meme si celui-ci echoue.
Remove-Item Env:PM_KS_PASS -ErrorAction SilentlyContinue
}
if ($Release) {
"APK signe : $stage\bin\pmserverApk.apk"
'Non installe : desinstaller d''abord la version de debogage effacerait le' +
' coffre local et les reglages.'
return
}
if ($NoInstall) { return }
# adb ecrit sa progression sur la sortie d'erreur, ce que PowerShell prend pour
# un echec. Sans ce relachement, une installation reussie interrompt le script.
$ErrorActionPreference = 'Continue'
& $adb install -r "$stage\bin\pmserverApk.apk" 2>&1 |
Select-String -Pattern 'Success|Failure' | ForEach-Object { $_.Line }
# Le telephone voit le poste comme 127.0.0.1 par ce renvoi.
& $adb reverse tcp:8080 tcp:80 | Out-Null
& $adb shell am force-stop com.embarcadero.pmserverApk
& $adb shell am start -n com.embarcadero.pmserverApk/com.embarcadero.firemonkey.FMXNativeActivity | Out-Null
+76
View File
@@ -0,0 +1,76 @@
# Genere les icones PNG du lanceur, pour Android < 8 qui ignore les icones
# adaptatives. Meme dessin que les vecteurs de android-res\drawable :
# bouclier blanc a serrure, sur le bleu de l'application.
#
# A relancer seulement si le dessin change ; les PNG sont suivis par git.
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$root = Join-Path $PSScriptRoot 'android-res'
$bg = [System.Drawing.Color]::FromArgb(255, 27, 53, 99) # #1B3563
$fg = [System.Drawing.Color]::FromArgb(255, 255, 255, 255) # blanc
# densite -> cote en pixels, tel qu'Android les attend
$sizes = @{ 'drawable-ldpi' = 36; 'drawable-mdpi' = 48; 'drawable-hdpi' = 72
'drawable-xhdpi' = 96; 'drawable-xxhdpi' = 144; 'drawable-xxxhdpi' = 192 }
foreach ($dir in $sizes.Keys) {
$n = $sizes[$dir]
$out = Join-Path $root $dir
New-Item -ItemType Directory -Force $out | Out-Null
$bmp = New-Object System.Drawing.Bitmap $n, $n
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = 'AntiAlias'
# Fond : carre arrondi. Les vieux lanceurs ne masquent pas, donc l'icone
# doit porter sa propre forme.
$r = [int]($n * 0.22)
$path = New-Object System.Drawing.Drawing2D.GraphicsPath
$path.AddArc(0, 0, 2*$r, 2*$r, 180, 90)
$path.AddArc($n-2*$r-1, 0, 2*$r, 2*$r, 270, 90)
$path.AddArc($n-2*$r-1, $n-2*$r-1, 2*$r, 2*$r, 0, 90)
$path.AddArc(0, $n-2*$r-1, 2*$r, 2*$r, 90, 90)
$path.CloseFigure()
$g.FillPath((New-Object System.Drawing.SolidBrush $bg), $path)
# Echelle : le dessin vectoriel vit dans un carre de 108.
$u = $n / 108.0
$brush = New-Object System.Drawing.SolidBrush $fg
# Bouclier : sommet, epaules, montants, puis les deux courbes qui se
# rejoignent en pointe. Memes coordonnees que ic_launcher_foreground.xml.
$s = New-Object System.Drawing.Drawing2D.GraphicsPath
$s.AddLines(@(
(New-Object System.Drawing.PointF ([float](54*$u), [float](29*$u))),
(New-Object System.Drawing.PointF ([float](77*$u), [float](37*$u))),
(New-Object System.Drawing.PointF ([float](77*$u), [float](56*$u)))))
$s.AddBezier(
[float](77*$u), [float](56*$u), [float](77*$u), [float](69.5*$u),
[float](67*$u), [float](78*$u), [float](54*$u), [float](82*$u))
$s.AddBezier(
[float](54*$u), [float](82*$u), [float](41*$u), [float](78*$u),
[float](31*$u), [float](69.5*$u), [float](31*$u), [float](56*$u))
$s.AddLine([float](31*$u), [float](56*$u), [float](31*$u), [float](37*$u))
$s.CloseFigure()
$g.FillPath($brush, $s)
# Serrure, evidee dans la couleur du fond. Le vecteur y arrive par un
# remplissage pair-impair ; ici, repeindre par-dessus revient au meme,
# l'icone ancienne etant de toute facon opaque.
$bgBrush = New-Object System.Drawing.SolidBrush $bg
$g.FillEllipse($bgBrush, [float](47*$u), [float](43*$u), [float](14*$u), [float](14*$u))
$slot = New-Object System.Drawing.Drawing2D.GraphicsPath
$slot.AddLines(@(
(New-Object System.Drawing.PointF ([float](51*$u), [float](55.5*$u))),
(New-Object System.Drawing.PointF ([float](57*$u), [float](55.5*$u))),
(New-Object System.Drawing.PointF ([float](58.6*$u), [float](70*$u))),
(New-Object System.Drawing.PointF ([float](49.4*$u), [float](70*$u)))))
$slot.CloseFigure()
$g.FillPath($bgBrush, $slot)
$g.Dispose()
$bmp.Save((Join-Path $out 'ic_launcher.png'), [System.Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
"{0,-20} {1}x{1}" -f $dir, $n
}
+73
View File
@@ -0,0 +1,73 @@
// Produces a pm-encrypted-export-v1 container with WebCrypto — the exact
// primitive the browser uses — so the Delphi/Android implementation can be
// checked against a known answer instead of "it seems to work on my file".
//
// node make-testvector.js > testvector.json
//
// The password and the payload are fixed below; keep them in sync with
// testvector.md. Salt and IV are fixed too, so the output is reproducible and
// a diff means a real behavioural change.
const { webcrypto } = require('node:crypto');
const { subtle } = webcrypto;
const PASSWORD = 'sync-test-password-éàü'; // deliberately NON-ASCII: that is
// where char[] vs UTF-8 diverges
const PAYLOAD = {
version: 1,
exported_at: '2026-08-14T12:00:00.000Z',
username: 'testvector',
folders: [{ name: 'Work', color: '#3b82f6', icon: '' }],
entries: [
{
uuid: '11111111-2222-3333-4444-555555555555',
site: 'example.com',
title: 'Example',
username: 'alice',
password: 'correct horse battery staple',
folder: 'Work',
tags: ['demo'],
kind: 'login',
deleted: 0,
},
{
uuid: '66666666-7777-8888-9999-000000000000',
site: 'trashed.example',
title: 'Trashed',
username: 'bob',
password: 'in the bin',
folder: 'All',
tags: [],
kind: 'login',
deleted: 1,
deleted_at: '2026-08-01 09:30:00',
},
],
};
// Fixed, so the file is byte-stable across runs. NEVER do this in the app:
// a reused salt/IV pair with the same key destroys GCM's security.
const salt = Buffer.alloc(32);
for (let i = 0; i < 32; i++) salt[i] = i;
const iv = Buffer.alloc(12);
for (let i = 0; i < 12; i++) iv[i] = 200 + i;
(async () => {
const base = await subtle.importKey('raw', new TextEncoder().encode(PASSWORD),
'PBKDF2', false, ['deriveKey']);
const key = await subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' },
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key,
new TextEncoder().encode(JSON.stringify(PAYLOAD)));
console.log(JSON.stringify({
format: 'pm-encrypted-export-v1',
kdf: 'pbkdf2-sha256',
kdf_iterations: 600000,
kdf_salt: salt.toString('base64'),
iv: iv.toString('base64'),
ciphertext: Buffer.from(ct).toString('base64'),
created_at: '2026-08-14T12:00:00.000Z',
}, null, 2));
})();
+139
View File
@@ -0,0 +1,139 @@
# Refait les trois dernieres etapes de l'empaquetage Android, sur le dossier
# deja prepare - sans le vider au prealable.
#
# POURQUOI : la cible Deploy de MSBuild reconstruit library\lib avant d'appeler
# aapt, ce qui efface toute bibliotheque native ajoutee a la main. Or ce projet
# n'a pas d'entrees de deploiement fonctionnelles (voir README), donc nos icones
# ne peuvent y arriver autrement. On laisse Deploy faire son travail, puis on
# remet ce qu'il a efface et on refait aapt / zipalign / signature.
#
# A lancer depuis la racine du depot, apres un Deploy reussi.
#
# -Config Release empaquette la sortie Release au lieu de Debug
# -Keystore <chemin> -Alias signe avec CETTE cle au lieu de celle de debogage
#
# LE MOT DE PASSE NE PASSE PAS PAR LA LIGNE DE COMMANDE. paclient --apksign
# l'y met en clair, ou n'importe quel processus de la machine peut le lire.
# apksigner sait lire une variable d'environnement ; on l'appelle donc en
# direct pour une vraie cle, et on ne garde paclient que pour la cle de
# debogage, dont le mot de passe est public et vaut "android".
param(
[ValidateSet('Debug', 'Release')] [string]$Config = 'Debug',
[string]$Keystore,
[string]$Alias
)
$ErrorActionPreference = 'Stop'
$sdk = 'C:\Users\Public\Documents\Embarcadero\Studio\23.0\CatalogRepository\AndroidSDK-2525-23.0.51511.6924'
$bds = 'C:\Program Files (x86)\Embarcadero\Studio\23.0'
$jdk = 'C:\Program Files\Eclipse Adoptium\jdk-11.0.15.10-hotspot'
$pac = "$bds\bin\paclient.exe"
$p = ".\Android64\$Config\pmserverApk"
# 0. LES RESSOURCES QUE LA COMPILATION GENERE, MISES EN PLACE A LA MAIN.
#
# Le Build les depose a plat dans Android64\<Config>\ - styles.xml,
# strings.xml, les variantes -v21/-v31, l'icone adaptative - et c'est le Deploy
# qui devrait les ranger dans res\. Il le fait en DEBUG et pas en RELEASE,
# raison inconnue : les proprietes Android_* du .dproj sont communes aux deux
# configurations, et aucune entree de deploiement ne les nomme.
#
# Sans elles, aapt refuse le paquet sur "@style/AppTheme" introuvable - le
# manifeste reference un theme que plus rien ne definit.
#
# Le nommage porte la destination : un fichier "nom-qualificatif.xml" va dans
# le sous-dossier "seau-qualificatif" sous le nom "nom.xml". On transcrit cette
# regle plutot que de la deviner, pour qu'un fichier nouveau se signale au lieu
# d'etre range au hasard.
#
# AVANT nos propres ressources, pour que celles-ci gardent le dernier mot -
# notre colors.xml doit l'emporter sur celui de Delphi.
$genRes = @{
'strings.xml' = 'values\strings.xml'
'styles.xml' = 'values\styles.xml'
'styles-v21.xml' = 'values-v21\styles.xml'
'styles-v31.xml' = 'values-v31\styles.xml'
'colors.xml' = 'values\colors.xml'
'colors-night-v21.xml' = 'values-night-v21\colors.xml'
'ic_launcher.xml' = 'drawable-anydpi-v26\ic_launcher.xml'
'ic_launcher-v33.xml' = 'drawable-anydpi-v33\ic_launcher.xml'
'splash_image_def.xml' = 'drawable\splash_image_def.xml'
'splash_image_def-v21.xml'= 'drawable-anydpi-v21\splash_image_def.xml'
}
$outDir = ".\Android64\$Config"
foreach ($k in $genRes.Keys) {
$src = "$outDir\$k"
if (-not (Test-Path $src)) { continue } # Delphi ne genere pas tout partout
$dst = Join-Path $p "res\$($genRes[$k])"
New-Item -ItemType Directory -Force -Path (Split-Path $dst) | Out-Null
Copy-Item $src $dst -Force
}
# 1. Nos icones par-dessus celles de Delphi :
# les proprietes Android_LauncherIcon* et Android_AdaptiveIcon* du .dproj
# devraient suffire, mais rien ne les deploie ici.
Copy-Item "$PSScriptRoot\android-res\*" "$p\res\" -Recurse -Force
# 1 ter. L'ecran de demarrage existe en plusieurs variantes de densite et de
# theme, toutes identiques chez nous. Une seule source, recopiee : quatre
# fichiers identiques dans le depot finiraient par diverger.
$splash = "$p\res\drawable\splash_vector.xml"
foreach ($v in 'drawable-anydpi-v31', 'drawable-night-anydpi-v21', 'drawable-night-anydpi-v31') {
if (Test-Path "$p\res\$v") { Copy-Item $splash "$p\res\$v\splash_vector.xml" -Force }
}
if (Test-Path "$p\res\drawable-anydpi-v21") {
Copy-Item "$p\res\drawable\splash_image_def.xml" "$p\res\drawable-anydpi-v21\splash_image_def.xml" -Force
}
# Le fond sombre vaut aussi en theme clair : l'application n'a qu'un theme.
if (Test-Path "$p\res\values-night-v21") {
Copy-Item "$PSScriptRoot\android-res\values\colors.xml" "$p\res\values-night-v21\colors.xml" -Force
}
# 1 quater. AAPT N'ACCEPTE QUE DES DOSSIERS A LA RACINE DE res\.
#
# Le deploiement de Delphi y depose aussi une copie a plat de cinq de ses
# ressources - ic_launcher_*, splash_* - alors que les vraies sont dans
# res\drawable\. aapt refuse alors le paquet entier : "invalid resource
# directory name", une fois par fichier. On les retire, et rien n'est perdu :
# ce sont des doublons, verifies presents dans drawable.
#
# Ne se voyait pas en debogage, ou l'arbre traine depuis longtemps et ou ces
# fichiers n'y avaient jamais ete deposes. C'est la premiere release qui l'a
# montre, comme le dossier de mise en scene absent juste avant.
Get-ChildItem "$p\res" -File -ErrorAction SilentlyContinue | Remove-Item -Force
# 2. aapt : reconstruit l'archive non alignee depuis library, classes, res.
& $pac -u8 --aaptpackage="$sdk\build-tools\33.0.2\Aapt.exe,$p\library,$p\classes,$p\res,$p\assets,$p\AndroidManifest.xml,$sdk\platforms\android-33\android.jar,$p\bin\pmserverApk-unaligned.apk"
if ($LASTEXITCODE -ne 0) { throw "aapt a echoue" }
# 3. zipalign refuse d'ecraser sa sortie : l'APK precedent doit disparaitre.
Remove-Item "$p\bin\pmserverApk.apk" -Force -ErrorAction SilentlyContinue
& $pac -u8 --zipalign="$sdk\build-tools\33.0.2\ZipAlign.exe,$p\bin\pmserverApk-unaligned.apk,$p\bin\pmserverApk.apk,4"
if ($LASTEXITCODE -ne 0) { throw "zipalign a echoue" }
if ($Keystore) {
# LA CLE DE L'UTILISATEUR. Le mot de passe arrive par PM_KS_PASS, pose par
# l'appelant juste avant et retire juste apres : il ne figure ni dans le
# depot, ni dans le .dproj, ni dans une ligne de commande.
if (-not $env:PM_KS_PASS) { throw 'PM_KS_PASS absent - mot de passe du magasin' }
& "$jdk\bin\java.exe" -jar "$sdk\build-tools\33.0.2\lib\apksigner.jar" sign `
--ks "$Keystore" --ks-key-alias "$Alias" `
--ks-pass env:PM_KS_PASS --key-pass env:PM_KS_PASS `
"$p\bin\pmserverApk.apk"
if ($LASTEXITCODE -ne 0) { throw "signature echouee" }
# DIRE AVEC QUOI ON A SIGNE. Une release signee par megarde avec la cle de
# debogage s'installe et fonctionne : rien ne la distingue a l'usage, et on
# ne s'en apercoit qu'au refus de la premiere mise a jour.
& "$jdk\bin\java.exe" -jar "$sdk\build-tools\33.0.2\lib\apksigner.jar" `
verify --print-certs "$p\bin\pmserverApk.apk" |
Select-String -Pattern 'Signer #1 certificate DN|SHA-256 digest' |
ForEach-Object { $_.Line.Trim() }
} else {
& $pac -u8 --apksign="$jdk\bin\java.exe,$sdk\build-tools\33.0.2\lib\apksigner.jar,$p\bin\pmserverApk.apk,androiddebugkey,$env:APPDATA\Embarcadero\BDS\23.0\debug.keystore,android,android"
if ($LASTEXITCODE -ne 0) { throw "signature echouee" }
}
$apk = Get-Item "$p\bin\pmserverApk.apk"
"APK {0:N1} Mo" -f ($apk.Length / 1MB)
+150
View File
@@ -0,0 +1,150 @@
program selftest;
{
Verification de la chaine crypto contre le vecteur connu, en console.
Remplace le bouton "Test crypto" qu'avait le banc d'essai : l'interface est
devenue celle d'un vrai client, et un aller-retour d'APK pour verifier une
constante est absurde. Ici la reponse arrive en quelques secondes, sur le
poste, avec un code de sortie exploitable.
Ce qu'il isole, dans cet ordre - une etape fausse rend inutile de lire les
suivantes :
1. PBKDF2 en Pascal -> la cle attendue ?
2. PBKDF2 natif (CNG) -> la MEME cle ? (l'encodage du mot de passe)
3. AES-GCM -> le clair attendu ?
Compilation :
dcc32 -B android-client\selftest.dpr
}
{$APPTYPE CONSOLE}
uses
System.SysUtils, System.Hash, System.NetEncoding, System.Diagnostics,
PMA.Crypto in 'PMA.Crypto.pas',
PMA.TestVector in 'PMA.TestVector.pas',
PMA.Totp in 'PMA.Totp.pas';
var
GFailures: Integer = 0;
procedure Check(const AName, AGot, AWant: string);
begin
if AGot = AWant then
Writeln(' OK ', AName)
else
begin
Writeln(' ECHEC ', AName);
Writeln(' obtenu : ', AGot);
Writeln(' attendu: ', AWant);
Inc(GFailures);
end;
end;
// URI plutot que base32 nu : elle porte les 8 chiffres de la RFC, et fait
// passer l'analyseur d'otpauth:// dans le meme mouvement.
const
RFC6238_URI = 'otpauth://totp/rfc?secret=' +
'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&digits=8';
procedure CheckTotp(AUnix: Int64; const AWant: string);
var
LLeft, LPeriod: Integer;
begin
Check('T=' + AUnix.ToString, TotpAt(RFC6238_URI, AUnix, LLeft, LPeriod), AWant);
end;
var
LSalt, LKey: TBytes;
LPlain: string;
LSw: TStopwatch;
begin
try
// Le sel vient du conteneur lui-meme : si le vecteur est regenere, le
// test suit sans qu'on ait a recopier quoi que ce soit.
LSalt := TNetEncoding.Base64.DecodeStringToBytes(
'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=');
Writeln('Mot de passe : ',
Length(TEncoding.UTF8.GetBytes(TESTVECTOR_PASSWORD)),
' octets UTF-8 (attendu 25)');
Writeln('PBKDF2 Pascal 600k...');
LSw := TStopwatch.StartNew;
LKey := DeriveKeyPBKDF2(TESTVECTOR_PASSWORD, LSalt, 600000, 32);
LSw.Stop;
Writeln(' ', LSw.ElapsedMilliseconds, ' ms');
Check('cle derivee', LowerCase(THash.DigestAsString(LKey)),
TESTVECTOR_KEY_HEX);
Writeln('PBKDF2 natif...');
LSw := TStopwatch.StartNew;
LKey := DeriveKeyPBKDF2Native(TESTVECTOR_PASSWORD, LSalt, 600000, 32);
LSw.Stop;
if LKey = nil then
begin
Writeln(' indisponible : ', LastNativeError);
Inc(GFailures);
end
else
begin
Writeln(' ', LSw.ElapsedMilliseconds, ' ms');
// Le point sensible : le mot de passe est non-ASCII, donc une cle
// identique prouve que le fournisseur encode bien en UTF-8.
Check('cle native identique', LowerCase(THash.DigestAsString(LKey)),
TESTVECTOR_KEY_HEX);
end;
Writeln('Conteneur AES-GCM...');
LPlain := DecryptContainer(TESTVECTOR_JSON, TESTVECTOR_PASSWORD);
Check('longueur du clair',
Length(TEncoding.UTF8.GetBytes(LPlain)).ToString,
TESTVECTOR_PLAIN_LEN.ToString);
Check('empreinte du clair',
LowerCase(THashSHA2.GetHashString(LPlain, THashSHA2.TSHA2Version.SHA256)),
TESTVECTOR_PLAIN_SHA);
// Un mot de passe faux DOIT echouer : sans cette verification, un
// dechiffrement qui rendrait n'importe quoi passerait pour un succes.
try
DecryptContainer(TESTVECTOR_JSON, 'mauvais mot de passe');
Writeln(' ECHEC un mot de passe faux a ete accepte');
Inc(GFailures);
except
on E: EPMACrypto do Writeln(' OK mot de passe faux rejete');
end;
except
on E: Exception do
begin
Writeln('ERREUR ', E.ClassName, ' : ', E.Message);
Inc(GFailures);
end;
end;
// ---- TOTP, vecteurs de la RFC 6238 --------------------------------------
// Secret "12345678901234567890" en base32, 8 chiffres, aux instants publies
// par la RFC. C'est le seul moyen de verifier a la fois la base32, le HMAC
// et la troncature - un code plausible peut etre faux.
Writeln('TOTP (RFC 6238)...');
try
CheckTotp(59, '94287082');
CheckTotp(1111111109, '07081804');
CheckTotp(1111111111, '14050471');
CheckTotp(1234567890, '89005924');
CheckTotp(2000000000, '69279037');
CheckTotp(20000000000, '65353130');
except
on E: Exception do
begin
Writeln(' ERREUR ', E.ClassName, ' : ', E.Message);
Inc(GFailures);
end;
end;
Writeln;
if GFailures = 0 then
Writeln('Tout est bon.')
else
Writeln(GFailures, ' echec(s).');
ExitCode := GFailures;
end.
+9
View File
@@ -0,0 +1,9 @@
{
"format": "pm-encrypted-export-v1",
"kdf": "pbkdf2-sha256",
"kdf_iterations": 600000,
"kdf_salt": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
"iv": "yMnKy8zNzs/Q0dLT",
"ciphertext": "31AqOJ83L2TEFOcYaKEdpqKWGY42I4NUq+jdO7pA3j8+v023ECxjSaEnB0NvbufW6dMCgkK9oFwRvoQlLVSvK6bNBwBinJ4ltlbwKqATR2HGHh5eqiqGZayNNdRIFNJZw/jw9lYy28R6uK421uaMRx+mUBs4LGX9U+F2ayFfVF/VlsaHb5I3YMhLCN2zyMxs3j7Y1s69N09Uonm0LGKQ+SiteuXTHWJzAvHFIduLlxPJhCfEKUap4PolQ5PMohJeJYt+sutg7UY6i0QclxpmF2MY2qpbh3gA2mmSntoI2LN8Q41NMqyVn6JY6kXXpEfD1aNVw6dDCqAw2FQJ9HxE+/GBirt009rdS/l59KEnIYRvgBFnQqRTJZNNncHXXh3TsnQ3opKEyYe5qCxnX6E7wGzRawD/sQn2NNWjR8wwQQ68Cs74/RR8stAeuD/VJhJnkwxXPh8eBXHWFjLgFZuan+Ja2FwkjMhkHvIqbfJpbNwnu1mJ8ABtbGqmEHYSGUvLovIwCoQWjBqewlCFvRg0l4h63mUxuLaXx0SrIA3xhS7HqnYVTBp5/EVAu715jKmeDHviiZw6iws5ujd/u0IDUtJY1uANaF1xTBwcfkCnuonBlI8eMbIlcndrLSUN2slm6KxeUjZ3RSpiE9Izh5ix4kann8lygCAZVGkjswB83LmB1dFNH3oqG3+4qUgv7qR0TRZUSMoopCaSxhsVRd6/uBWu+82Nkbij1jMIzaB1+vTxBEHi5DGBNDfV/0evTgoIkC9wJtlEbz/OVCwC",
"created_at": "2026-08-14T12:00:00.000Z"
}
+66
View File
@@ -0,0 +1,66 @@
# Vecteur de test — conteneur `pm-encrypted-export-v1`
`testvector.json` est produit par `make-testvector.js` avec **WebCrypto**, la
primitive exacte du navigateur. Sel et IV sont figés, donc le fichier est
reproductible : une différence signale un vrai changement de comportement, pas
du bruit.
> Le fichier de test réutilise un couple sel/IV fixe. C'est acceptable pour un
> vecteur, **jamais** dans l'application : réutiliser un IV avec la même clé
> détruit la sécurité de GCM.
## Paramètres
| | |
|---|---|
| Mot de passe | `sync-test-password-éàü` |
| KDF | PBKDF2-SHA256, 600 000 itérations, sel de 32 octets |
| Chiffrement | AES-256-GCM, IV de 12 octets, tag de 16 octets **en fin de `ciphertext`** |
Le mot de passe est **volontairement non-ASCII**. C'est le seul cas qui
distingue une implémentation correcte d'une implémentation approximative : Java
`PBEKeySpec` prend un `char[]` et laisse le fournisseur choisir l'encodage, ce
qui donne une clé différente pour `é`, `à`, `ü`. Un vecteur en pur ASCII aurait
laissé passer le bug.
## Valeurs attendues
Vérifier **dans cet ordre** — chaque étage isole une cause :
**1. Clé dérivée** (`DeriveKeyPBKDF2`, avant tout déchiffrement)
```
6bd7683ea78e163062f02681b03f2906fcf5a3a25025005af4e71982e6e2b286
```
Si elle diffère, le problème est dans PBKDF2 ou dans l'encodage du mot de passe
— surtout pas dans AES. Contrôler d'abord que le mot de passe est bien encodé
en UTF-8 (`sync-test-password-éàü` fait **25 octets**, pas 22 caractères).
**2. Texte clair** (`AesGcmDecrypt` puis `DecryptContainer`)
```
longueur UTF-8 : 572 octets
SHA-256 : ddaee44f8ac8374f1ece6d9ee2abd104d59cd283556f1c4c07afa95ae1dbd2a7
début : {"version":1,"exported_at":"2026-08-14T12:00:00.000Z","username":"test
```
Si la clé est bonne et que le déchiffrement échoue, chercher du côté de la
longueur du tag (128 **bits**) ou d'un tag qui aurait été séparé du corps —
WebCrypto le concatène, `javax.crypto` l'attend concaténé, il n'y a rien à
découper.
## Contenu
2 entrées, dont **une en corbeille** (`deleted: 1` avec son `deleted_at`), et
un dossier avec sa couleur. De quoi vérifier que le client mobile affiche la
corbeille séparément au lieu de la mélanger à la liste.
## Régénérer
```bash
node make-testvector.js > testvector.json
```
En PowerShell, la redirection ajoute un **BOM** qui rend le JSON illisible.
Utiliser `cmd`, ou écrire le fichier sans BOM.
+51
View File
@@ -0,0 +1,51 @@
// Ouvre un conteneur pm-encrypted-export-v1 avec Node, pour distinguer un
// mauvais mot de passe d'un defaut du client Delphi.
//
// node try-open.js <fichier> <mot-de-passe>
//
// Utilise les memes primitives que le navigateur. Si Node y arrive et pas
// Delphi, le probleme est dans le client ; si les deux echouent, c'est le
// mot de passe.
const fs = require('node:fs');
const c = require('node:crypto');
const [, , file, pwd] = process.argv;
if (!file || pwd === undefined) {
console.error('usage: node try-open.js <fichier> <mot-de-passe>');
process.exit(2);
}
const raw = fs.readFileSync(file, 'utf8').replace(/^/, '');
let v;
try {
v = JSON.parse(raw);
} catch (e) {
console.error('Ce fichier n\'est pas du JSON : ' + e.message);
process.exit(1);
}
console.log('format : ' + v.format);
console.log('kdf : ' + v.kdf + ' / ' + v.kdf_iterations + ' iterations');
console.log('mot de passe : ' + pwd.length + ' caracteres, ' +
Buffer.byteLength(pwd, 'utf8') + ' octets UTF-8');
const salt = Buffer.from(v.kdf_salt, 'base64');
const iv = Buffer.from(v.iv, 'base64');
const ct = Buffer.from(v.ciphertext, 'base64');
const key = c.pbkdf2Sync(Buffer.from(pwd, 'utf8'), salt, v.kdf_iterations || 600000, 32, 'sha256');
console.log('cle derivee : ' + key.toString('hex'));
try {
const d = c.createDecipheriv('aes-256-gcm', key, iv);
d.setAuthTag(ct.subarray(ct.length - 16));
const pt = Buffer.concat([d.update(ct.subarray(0, ct.length - 16)), d.final()]).toString('utf8');
const obj = JSON.parse(pt);
const live = (obj.entries || []).filter(e => !e.deleted).length;
const trash = (obj.entries || []).filter(e => e.deleted).length;
console.log('==> OUVERT : ' + live + ' entrees, ' + trash + ' en corbeille');
console.log(' exporte le ' + (obj.exported_at || obj.snapshot_at || '?'));
} catch (e) {
console.log('==> ECHEC : ' + e.message);
console.log(' Le tag GCM ne verifie pas : mot de passe faux, ou fichier altere.');
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
PMAPP RCDATA "app.html"
+224 -61
View File
@@ -236,6 +236,22 @@ input[type="range"]::-webkit-slider-thumb {
radial-gradient(ellipse at bottom right, rgba(167,139,250,0.08), transparent 50%), radial-gradient(ellipse at bottom right, rgba(167,139,250,0.08), transparent 50%),
var(--bg); var(--bg);
} }
#licenceScreen { z-index: 300; }
/* L'empreinte se recopie a la main dans un courriel : chasse fixe, selectionnable,
et assez grande pour qu'un 8 ne passe pas pour un B. */
.licence-machine {
margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--border);
text-align: center;
}
.licence-machine span { display: block; color: var(--text-dim); font-size: 11px; margin-bottom: 6px; }
.licence-machine code {
display: inline-block; padding: 6px 10px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
font-size: 14px; letter-spacing: 1px; color: var(--text);
user-select: all;
}
.auth-card { .auth-card {
width: 100%; max-width: 400px; width: 100%; max-width: 400px;
padding: 32px; padding: 32px;
@@ -291,6 +307,14 @@ input[type="range"]::-webkit-slider-thumb {
text-align: center; text-align: center;
} }
.auth-hint:empty { display: none; } .auth-hint:empty { display: none; }
/* Le cyan de .auth-hint dit "voici l'etat des choses" (session expiree,
coffre verrouille). Un refus n'est pas un etat, c'est un echec : il se
distingue a la couleur avant d'etre lu. */
.auth-hint.is-error {
background: var(--danger-soft);
border-color: var(--danger);
color: var(--danger);
}
/* Tag chip input */ /* Tag chip input */
.chip-input { .chip-input {
@@ -368,27 +392,6 @@ input[type="range"]::-webkit-slider-thumb {
.chip button:hover { opacity: 1; background: rgba(0,0,0,0.1); } .chip button:hover { opacity: 1; background: rgba(0,0,0,0.1); }
.chip button svg { width: 10px; height: 10px; } .chip button svg { width: 10px; height: 10px; }
/* Tag suggestions dropdown */
.chip-suggestions {
position: absolute;
z-index: 50;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 4px;
max-height: 200px;
overflow-y: auto;
min-width: 160px;
}
.chip-suggestion {
padding: 6px 10px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
}
.chip-suggestion:hover, .chip-suggestion.is-active { background: var(--bg-elev-2); color: var(--text); }
.chip-suggestion-empty { padding: 6px 10px; color: var(--text-faint); font-size: 12px; }
/* Settings rows */ /* Settings rows */
.setting-row { .setting-row {
@@ -799,8 +802,28 @@ input[type="range"]::-webkit-slider-thumb {
.dropdown-item:hover { background: var(--bg-elev-2); } .dropdown-item:hover { background: var(--bg-elev-2); }
.dropdown-item svg { color: var(--text-dim); } .dropdown-item svg { color: var(--text-dim); }
.content { flex: 1; padding: 24px 32px; overflow-y: auto; } /* No padding-top: the sticky .content-header owns it, so its box starts flush
.content-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; } with the scrollport and nothing can scroll through above it. */
.content { flex: 1; padding: 0 32px 24px; overflow-y: auto; }
/* Sticky title row — carries .content's top padding so its opaque box reaches
the scrollport edge. Everything else that sticks (batch bar, pagination,
table header) offsets by --hdr-h. */
.content-header {
display: flex; align-items: baseline; gap: 12px;
/* padding, NOT margin: offsetHeight (which feeds --hdr-h, where the sticky
pagination parks) ignores margins, so the 20px gap vanished as soon as
the header stuck. As padding it is measured AND painted — the gap stays
constant and stays opaque instead of letting rows scroll through it. */
padding-bottom: 20px;
/* FIXED, not min-height: 24 top padding + 38 bar + 20 bottom padding. A
minimum still lets a child grow the row — a couple of stray pixels from
the selection bar were enough to push the whole grid down in table and
list view. With a fixed height nothing inside can move what follows. */
height: 82px;
position: sticky; top: 0; z-index: 12;
background: var(--bg);
padding-top: 24px;
}
.content-header h2 { font-size: 22px; font-weight: 600; margin: 0; } .content-header h2 { font-size: 22px; font-weight: 600; margin: 0; }
.content-meta { color: var(--text-faint); font-size: 12px; } .content-meta { color: var(--text-faint); font-size: 12px; }
@@ -868,7 +891,9 @@ input[type="range"]::-webkit-slider-thumb {
.entry-grid.is-list .entry-note-row { display: contents; } .entry-grid.is-list .entry-note-row { display: contents; }
.entry-grid.is-list .entry-note-placeholder { .entry-grid.is-list .entry-note-placeholder {
order: 3; order: 3;
flex: 1; /* Same track as .entry-pw so note rows line their copy button up with
the password rows'. Full text on hover via the title attr. */
flex: 0 0 110px;
min-width: 0; min-width: 0;
font-size: 11px; font-size: 11px;
color: var(--text-dim); color: var(--text-dim);
@@ -909,8 +934,9 @@ input[type="range"]::-webkit-slider-thumb {
.entry-grid.is-list .entry-pw { .entry-grid.is-list .entry-pw {
order: 3; order: 3;
font-size: 11px; font-size: 11px;
width: 110px; /* Full shorthand, not width: the base .entry-pw is `flex: 1` (basis 0),
flex-shrink: 0; which ignores width and let the pill stretch across the whole row. */
flex: 0 0 110px;
padding: 4px 8px; padding: 4px 8px;
background: var(--bg); background: var(--bg);
border-radius: 6px; border-radius: 6px;
@@ -925,7 +951,7 @@ input[type="range"]::-webkit-slider-thumb {
order: 5; order: 5;
/* Absorb the free space freed up by the now-fixed title, pushing tags/ /* Absorb the free space freed up by the now-fixed title, pushing tags/
folder + the order-6 actions to the far right while pw stays put. folder + the order-6 actions to the far right while pw stays put.
(When a row has no meta, the order-6 rule below carries the auto.) */ Sole auto margin in the row — see the note below the order-6 block. */
margin: 0 0 0 auto; margin: 0 0 0 auto;
flex-shrink: 0; flex-shrink: 0;
max-width: 220px; max-width: 220px;
@@ -947,12 +973,10 @@ input[type="range"]::-webkit-slider-thumb {
order: 6; order: 6;
flex-shrink: 0; flex-shrink: 0;
} }
/* First trailing action floats right too, so rows WITHOUT an entry-meta /* NOTE: no margin-left:auto here. .entry-meta is always in the DOM (empty
still push their actions to the far edge (margin-left:auto on the first when the entry has no chips) and already carries the auto, so it absorbs
flex item consumes the free space; later ones are no-ops). */ all the free space. A second auto margin would SPLIT the free space
.entry-grid.is-list .entry-fav, between the two, drifting the chips left/right depending on row width. */
.entry-grid.is-list .entry-kebab-wrap,
.entry-grid.is-list .entry-head .icon-btn { margin-left: auto; }
/* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar /* Selection checkbox overlay — nested INSIDE the avatar (.entry-avatar
is position:relative) so it covers the avatar 1:1, no overlap with the is position:relative) so it covers the avatar 1:1, no overlap with the
avatar's footprint. Hidden by default; appears on card hover OR when avatar's footprint. Hidden by default; appears on card hover OR when
@@ -997,14 +1021,20 @@ input[type="range"]::-webkit-slider-thumb {
font-size: 11px; font-size: 11px;
} }
/* Batch action bar (appears when selection is non-empty) */ /* Batch action bar (appears when selection is non-empty). It lives INSIDE
.content-header, to the right of the title: as a block of its own it pushed
the whole list down on every selection. The header reserves its height (see
.content-header min-height) so appearing/disappearing shifts nothing. */
.batch-bar { .batch-bar {
position: sticky; flex: 1;
top: -1px; align-self: center;
z-index: 10; /* EXACTLY the height .content-header reserves. Left to grow, the bar was a
few pixels taller than the reservation and pushed the whole grid down as
soon as anything was selected — the shift the sticky header was meant to
prevent in the first place. */
height: 38px;
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 10px 14px; padding: 0 10px;
margin-bottom: 16px;
background: var(--bg-elev); background: var(--bg-elev);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-radius: var(--radius); border-radius: var(--radius);
@@ -1358,6 +1388,38 @@ input[type="range"]::-webkit-slider-thumb {
text-transform: uppercase; letter-spacing: 0.5px; text-transform: uppercase; letter-spacing: 0.5px;
margin-top: 2px; margin-top: 2px;
} }
/* Score dial + its per-category breakdown, stacked. */
.health-score-col {
display: flex; flex-direction: column;
align-items: center; gap: 10px;
flex-shrink: 0;
}
.health-score-breakdown {
display: flex; flex-direction: column;
gap: 2px;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
/* Each row keeps its own box: a display:contents row would have none, and the
title tooltip explaining the cap would never appear. */
.health-bd-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0 10px;
cursor: help;
}
.health-bd-n { text-align: right; font-variant-numeric: tabular-nums; }
.health-bd-pts {
text-align: right;
font-family: 'JetBrains Mono', ui-monospace, monospace;
color: var(--text-faint);
}
/* Nothing wrong in this category — dim it so the eye goes to what needs work. */
.health-bd-row.is-clear .health-bd-lbl,
.health-bd-row.is-clear .health-bd-n,
.health-bd-row.is-clear .health-bd-pts { opacity: 0.45; }
.health-intro { flex: 1; } .health-intro { flex: 1; }
.health-intro h3 { margin: 0 0 6px; font-size: 16px; } .health-intro h3 { margin: 0 0 6px; font-size: 16px; }
.health-intro p { .health-intro p {
@@ -1599,15 +1661,54 @@ input[type="range"]::-webkit-slider-thumb {
margin-top: 6px; margin-top: 6px;
} }
.so-custom-row { .so-custom-row {
/* label | value (eye inside) | row menu */
display: grid; display: grid;
grid-template-columns: 1fr 1.4fr auto auto auto auto; grid-template-columns: 1fr 1.4fr auto;
gap: 4px; gap: 4px;
align-items: center; align-items: center;
} }
/* The reveal eye sits inside the field, like the TOTP one. */
.so-custom-value-wrap { position: relative; display: flex; min-width: 0; }
.so-custom-value-wrap > .so-input,
.so-custom-value-wrap > .so-combo { width: 100%; }
.so-custom-value-wrap > .so-input { padding-right: 28px; }
.so-custom-value-eye {
position: absolute;
right: 3px;
top: 50%;
transform: translateY(-50%);
}
/* Multiline: pin it to the top corner, clear of the resize grip — and inside
the scrollbar, which appears as soon as the content passes the auto-grow cap.
scrollbar-gutter keeps that space reserved so the eye doesn't shift when the
scrollbar shows up. */
.so-custom-value-wrap.is-multiline .so-custom-value-eye {
top: 3px;
right: 16px;
transform: none;
}
.so-custom-value-wrap.is-multiline > .so-input {
padding-right: 44px;
scrollbar-gutter: stable;
}
/* The row menu is absolutely positioned inside its wrap; without this the
slideover clips it or it lands under the next row. */
.so-custom-row .entry-kebab-wrap { position: relative; }
.so-custom-row .entry-kebab-menu { right: 0; }
.so-custom-row .so-input { .so-custom-row .so-input {
padding: 4px 8px; padding: 4px 8px;
font-size: 12px; font-size: 12px;
} }
/* Multiline custom field (Notes). The row stays a grid, so the textarea just
grows in its own column and the buttons keep their places. */
.so-custom-multiline {
min-height: 56px;
resize: vertical;
align-self: stretch;
font-family: inherit;
line-height: 1.5;
overflow-y: auto; /* JS caps the auto-grow; past that it scrolls */
}
/* Editable combobox for option-backed custom fields (card brand, expiry /* Editable combobox for option-backed custom fields (card brand, expiry
year…). Input fills the cell; the arrow drops a menu of ALL options. */ year…). Input fills the cell; the arrow drops a menu of ALL options. */
.so-combo { position: relative; display: flex; align-items: center; } .so-combo { position: relative; display: flex; align-items: center; }
@@ -1641,22 +1742,10 @@ input[type="range"]::-webkit-slider-thumb {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.so-combo-item:hover { background: var(--accent); color: #fff; } .so-combo-item:hover { background: var(--accent); color: #fff; }
.so-custom-secret-toggle { /* The eye carries the secret state itself — a second toggle beside it read as
background: var(--bg); a duplicate of the reveal. Flipping the flag lives in the row menu. */
border: 1px solid var(--border); /* No marker for "stored as secret": the eye only exists on a secret field —
border-radius: 4px; Make public removes it — so its mere presence already says so. */
width: 28px; height: 28px;
cursor: pointer;
color: var(--text-dim);
font-size: 13px;
line-height: 1;
padding: 0;
}
.so-custom-secret-toggle.is-on {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent);
}
.so-custom-add { .so-custom-add {
margin-top: 8px; margin-top: 8px;
} }
@@ -1880,6 +1969,11 @@ input[type="range"]::-webkit-slider-thumb {
line-height: 1.55; line-height: 1.55;
white-space: pre-wrap; white-space: pre-wrap;
} }
/* A textarea has no type="password"; -webkit-text-security is the Chromium
equivalent (WebView2 is Chromium, so it's always available here). */
.so-note-body.is-masked,
.so-custom-multiline.is-masked { -webkit-text-security: disc; }
.so-note-label { display: flex; align-items: center; justify-content: space-between; }
/* ---- + New dropdown ---------------------------------- */ /* ---- + New dropdown ---------------------------------- */
.new-entry-wrap { .new-entry-wrap {
@@ -1933,6 +2027,11 @@ input[type="range"]::-webkit-slider-thumb {
} }
/* Table column picker dropdown — only rendered in table view. */ /* Table column picker dropdown — only rendered in table view. */
.cols-wrap {
position: relative; /* anchor for the absolutely-positioned menu */
display: inline-flex;
vertical-align: middle;
}
.cols-menu { .cols-menu {
position: absolute; position: absolute;
top: calc(100% + 4px); top: calc(100% + 4px);
@@ -2281,6 +2380,29 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
animation: busy-spin 0.7s linear infinite; animation: busy-spin 0.7s linear infinite;
} }
@keyframes busy-spin { to { transform: rotate(360deg); } } @keyframes busy-spin { to { transform: rotate(360deg); } }
/* Breach-scan progress. Non-blocking: no backdrop, no pointer capture.
Lives INSIDE .toast-container, so it stacks with the toasts in the same
corner instead of floating over the cards (pinned to the left edge it also
covered the sidebar navigation). column-reverse puts it above them. */
.hibp-progress {
align-self: flex-end;
display: flex; align-items: center; gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-elev);
border: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
font-size: 12px; color: var(--text-dim);
pointer-events: none;
}
.hibp-progress-spinner {
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: busy-spin 0.7s linear infinite;
flex-shrink: 0;
}
.busy-text { font-size: 13px; color: var(--text-dim); text-align: center; } .busy-text { font-size: 13px; color: var(--text-dim); text-align: center; }
.slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } .slideover-field-label { font-size: 11px; font-weight: 500; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.slideover-field-value { .slideover-field-value {
@@ -2549,6 +2671,19 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
min-width: 240px; min-width: 240px;
animation: toastIn var(--t-base); animation: toastIn var(--t-base);
} }
/* Errors / warnings linger — let the user clear them instead of waiting. */
.toast.is-dismissable { cursor: pointer; }
/* Settings entry point when a hotkey is dead: a dot the user meets in the
normal UI, not only inside the panel they may never open. */
#settingsBtn.has-alert { position: relative; }
#settingsBtn.has-alert::after {
content: "";
position: absolute;
top: 6px; right: 8px;
width: 7px; height: 7px;
border-radius: 50%;
background: var(--warning, #e0a800);
}
.toast.is-success { border-left-color: var(--success); } .toast.is-success { border-left-color: var(--success); }
.toast.is-error { border-left-color: var(--danger); } .toast.is-error { border-left-color: var(--danger); }
.toast.is-warning { border-left-color: var(--warning); } .toast.is-warning { border-left-color: var(--warning); }
@@ -2575,7 +2710,8 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
.sidebar-section-header { display: none; } .sidebar-section-header { display: none; }
.nav-item { justify-content: center; padding: 8px; } .nav-item { justify-content: center; padding: 8px; }
.topbar { padding: 12px 16px; } .topbar { padding: 12px 16px; }
.content { padding: 16px; } .content { padding: 0 16px 16px; }
.content-header { padding-top: 16px; height: 74px; } /* 16 + 38 + 20 */
.slideover { width: 100%; } .slideover { width: 100%; }
/* On a narrow viewport the slideover fills the screen — no point /* On a narrow viewport the slideover fills the screen — no point
pushing the shell, the table isn't visible anyway. */ pushing the shell, the table isn't visible anyway. */
@@ -2614,6 +2750,16 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
min-width: 140px; min-width: 140px;
text-align: center; text-align: center;
} }
/* Combo refused by Windows (another app holds it) — the hotkey is dead, so the
row has to say so long after the toast is gone. ⚠ via ::after so no markup
change is needed when the state flips. */
.hotkey-capture-btn.is-conflict {
border-color: var(--warning, #e0a800);
color: var(--warning, #e0a800);
}
.hotkey-capture-btn.is-conflict::after {
content: " ⚠";
}
.hotkey-capture-btn.is-capturing { .hotkey-capture-btn.is-capturing {
background: var(--accent); background: var(--accent);
color: var(--accent-fg, #fff); color: var(--accent-fg, #fff);
@@ -2671,8 +2817,10 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: var(--bg-elev); background: var(--bg-elev);
user-select: none; user-select: none;
position: sticky; top: 0; position: sticky; top: calc(var(--hdr-h, 0px) + var(--pag-h, 0px));
z-index: 1; /* Above .entry-check (2), below .pagination (4) — the row checkboxes were
painted over this header for the same reason as in card view. */
z-index: 3;
} }
.entry-table th.is-sortable { cursor: pointer; } .entry-table th.is-sortable { cursor: pointer; }
.entry-table th.is-sortable:hover { color: var(--text-dim); } .entry-table th.is-sortable:hover { color: var(--text-dim); }
@@ -2957,11 +3105,26 @@ input[type="password"]::-ms-clear {
} }
.entry-card.is-flash { animation: entryFlash 2.5s ease-out; } .entry-card.is-flash { animation: entryFlash 2.5s ease-out; }
.entry-row.is-flash { animation: entryFlash 2.5s ease-out; } .entry-row.is-flash { animation: entryFlash 2.5s ease-out; }
/* Le meme battement pour un bloc de reglage vers lequel on vient d'etre
conduit : "Sync now" sans configuration ouvre ce panneau et descend ici, et
l'animation dit lequel des blocs on est venu chercher. Reutilisee plutot que
redefinie - deux pulsations differentes pour le meme sens se remarqueraient. */
.slideover-field.is-flash { animation: entryFlash 2.5s ease-out; }
/* Pagination footer (list + table views). */ /* Pagination bar (all view modes). Sticky so the page buttons stay reachable
while scrolling a long page; z-index beats the sticky table header, which
is pushed down by --pag-h (measured in renderEntries). */
.pagination { .pagination {
position: sticky;
top: var(--hdr-h, 0px);
/* Sticky layers, bottom to top: .entry-check 2 · table header 3 ·
pagination 4 · .content-header 12. At EQUAL z-index the later element in
the DOM wins, and the cards come after this bar — which is why the
checkboxes of the rows scrolling underneath used to cover it. */
z-index: 4;
background: var(--bg);
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 14px 12px 4px; padding: 14px 12px 10px;
flex-wrap: wrap; flex-wrap: wrap;
font-size: 13px; font-size: 13px;
color: var(--text-dim); color: var(--text-dim);
+18 -182
View File
@@ -206,8 +206,10 @@ begin
LObj.AddPair('csrfToken', ACSRFToken); LObj.AddPair('csrfToken', ACSRFToken);
// kdfIterations is the iteration count the client must use when deriving // kdfIterations is the iteration count the client must use when deriving
// the AES-GCM key for THIS session — matches the count under which the // the AES-GCM key for THIS session — matches the count under which the
// existing entries are encrypted. If the server signals migration, the // existing entries are encrypted. NOTE: the kdfMigration signal below is
// client should re-encrypt with the new target and call /migrate-kdf. // now VESTIGIAL — /migrate-kdf was disabled (§6c-1) and the client ignores
// it. Legacy accounts upgrade via /change-master-password instead. Kept to
// avoid churning the login hot path; harmless (endpoint returns 410).
LObj.AddPair('kdfIterations', TJSONNumber.Create(AKdfIterations)); LObj.AddPair('kdfIterations', TJSONNumber.Create(AKdfIterations));
if ANeedsMigration then if ANeedsMigration then
begin begin
@@ -643,26 +645,18 @@ begin
end; end;
// ===== /migrate-kdf ========================================================== // ===== /migrate-kdf ==========================================================
// Atomic transition from an old PBKDF2 iteration count to the current target. // DISABLED 2026-07-16 (§6c-1 / §6c-4). This path re-derived the AES key
// Client side: derive both old and new AES keys, decrypt each entry with old, // (100k -> 600k) but re-encrypted ONLY encrypted_password — totp_secret,
// re-encrypt with new, then POST the new ciphertext blob to this endpoint // custom_fields, the *_enc metadata + attachments stayed under the old key =
// along with the master password (so we can recompute the new server hash). // data loss — and it would rewrite a -v2 account's hash_algo, permanently
// Server side: verify the master pw with the old hash, then in a single // locking it out. The client no longer calls it; any stray call is rejected so
// transaction: update users.password_hash to the new PBKDF2 output, set // it can neither corrupt nor brick a vault. Legacy <600k accounts upgrade
// kdf_iterations to TARGET, and replace each entry's encrypted_password/iv. // safely via /change-master-password (re-encrypts everything + moves to
// All-or-nothing: if anything fails, the user stays on the old config. // Argon2id).
procedure HandleMigrateKdf(ARequest: TIdHTTPRequestInfo; procedure HandleMigrateKdf(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>); AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var var
LUserId, LOldIters, I: Integer; LUserId: Integer;
LBody, LEntry: TJSONObject;
LEntries: TJSONArray;
LUser, LPwd, LOldVerifier, LNewVerifier, LSalt, LStoredHash, LAlgo, LIP,
LComputed, LNewHash: string;
LQ: TFDQuery;
LValid: Boolean;
LEntryId: Integer;
LEncPwd, LIv: string;
begin begin
try try
LUserId := Authenticate(ARequest, AResponse); LUserId := Authenticate(ARequest, AResponse);
@@ -670,166 +664,8 @@ begin
except except
on ESessionRejected do Exit; on ESessionRejected do Exit;
end; end;
TJSONHelper.SendError(AResponse, 410,
LIP := GetClientIP(ARequest); 'KDF migration disabled — rotate the master password instead');
LBody := TJSONHelper.ReadBody(ARequest);
try
LPwd := LBody.GetValue<string>('masterPassword', '');
// ZK path: client provides PBKDF2 hex at the OLD iter count (oldVerifier,
// for current-hash verification) AND at the new TARGET iter count
// (newVerifier, for the post-migration stored hash).
LOldVerifier := LBody.GetValue<string>('oldVerifier', '');
LNewVerifier := LBody.GetValue<string>('newVerifier', '');
LEntries := LBody.GetValue<TJSONArray>('entries');
if LEntries = nil then
begin
TJSONHelper.SendError(AResponse, 400, 'Missing entries array');
Exit;
end;
DB.Lock;
try
// Step 1: load current user state.
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' +
'FROM users WHERE id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if LQ.IsEmpty then
begin
TJSONHelper.SendError(AResponse, 401, 'User not found');
Exit;
end;
LUser := LQ.FieldByName('username').AsString;
LStoredHash := LQ.FieldByName('password_hash').AsString;
LSalt := LQ.FieldByName('salt').AsString;
LAlgo := LQ.FieldByName('hash_algo').AsString;
LOldIters := LQ.FieldByName('kdf_iterations').AsInteger;
if LAlgo = '' then LAlgo := 'pbkdf2';
if LOldIters <= 0 then LOldIters := PBKDF2_ITERATIONS;
finally
LQ.Free;
end;
// Idempotency: nothing to do if BOTH iter count is at target AND
// hash format is current. Previously we short-circuited on iter
// count alone, which would have skipped the hash-format upgrade for
// users who migrated KDF before this commit landed.
if (LOldIters >= PBKDF2_ITERATIONS_TARGET) and
SameText(LAlgo, HASH_ALGO_CURRENT) then
begin
TJSONHelper.SendOK(AResponse, 'Already at target');
Exit;
end;
// Step 2: verify the master pw against the CURRENT (old) hash. Prefer
// the ZK verifier path; fall back to plaintext for legacy clients.
LValid := False;
if LOldVerifier <> '' then
LValid := CheckVerifier(LOldVerifier, LStoredHash, LAlgo)
else if SameText(LAlgo, HASH_ALGO_LEGACY) then
begin
LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, LOldIters);
LValid := ConstantTimeEquals(LComputed, LStoredHash);
end
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
begin
LComputed := ComputeAuthHashCurrent(LPwd, LSalt, LOldIters);
LValid := ConstantTimeEquals(LComputed, LStoredHash);
end;
if not LValid then
begin
RecordFailedAccountAttempt(LUser, LIP);
LogAudit(LUserId, 'failed_migrate_kdf', LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid password');
Exit;
end;
// Step 3: compute the new password hash under the current scheme
// (SHA-256 wrap) at the target iter count. ZK path takes the
// newVerifier (PBKDF2 at the target iters, computed client-side) and
// just wraps it; plaintext path runs PBKDF2 server-side.
if LNewVerifier <> '' then
begin
if not IsValidVerifier(LNewVerifier) then
begin
TJSONHelper.SendError(AResponse, 400, 'Malformed newVerifier');
Exit;
end;
LNewHash := VerifierToStoredHash(LNewVerifier, HASH_ALGO_CURRENT);
end
else
LNewHash := ComputeAuthHashCurrent(LPwd, LSalt, PBKDF2_ITERATIONS_TARGET);
// Step 4: atomic transaction — update user hash AND every entry's
// ciphertext together. Any failure rolls back, leaving the user on
// the legacy config (safe to retry next login).
DB.Connection.StartTransaction;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Update hash, iter count, AND hash_algo all in one row update.
// hash_algo := HASH_ALGO_CURRENT is what completes the migration
// away from the "stored hash IS the AES key" architectural flaw.
LQ.SQL.Text :=
'UPDATE users SET password_hash = :h, kdf_iterations = :it, ' +
' hash_algo = :algo ' +
'WHERE id = :uid';
LQ.ParamByName('h').AsString := LNewHash;
LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET;
LQ.ParamByName('algo').AsString := HASH_ALGO_CURRENT;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE vault_entries ' +
'SET encrypted_password = :ep, iv = :iv, updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid';
for I := 0 to LEntries.Count - 1 do
begin
LEntry := LEntries.Items[I] as TJSONObject;
LEntryId := LEntry.GetValue<Integer>('id', 0);
LEncPwd := LEntry.GetValue<string>('encrypted_password', '');
LIv := LEntry.GetValue<string>('iv', '');
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
LQ.ParamByName('id').AsInteger := LEntryId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ep').AsString := LEncPwd;
LQ.ParamByName('iv').AsString := LIv;
LQ.ExecSQL;
end;
finally
LQ.Free;
end;
DB.Connection.Commit;
except
DB.Connection.Rollback;
raise;
end;
finally
DB.Unlock;
end;
finally
LBody.Free;
end;
LogAudit(LUserId, Format('migrate_kdf %d->%d', [LOldIters, PBKDF2_ITERATIONS_TARGET]), LIP);
TJSONHelper.SendOK(AResponse, 'Migration complete');
end; end;
// ===== POST /change-master-password ========================================== // ===== POST /change-master-password ==========================================
@@ -852,9 +688,9 @@ end;
// working past the pw change. // working past the pw change.
// - Writes an audit_log entry. // - Writes an audit_log entry.
// //
// The /migrate-kdf endpoint exists for the same "re-encrypt all entries" // This is now the ONLY correct "re-encrypt all entries under a new key" path
// pattern when the master pw stays the same; this endpoint differs by // (the old /migrate-kdf was disabled in §6c-1 for re-encrypting just 1 blob
// rotating the salt + pw too. // of 8). Legacy <600k accounts reach 600k + Argon2id through here.
procedure HandleChangeMasterPassword(ARequest: TIdHTTPRequestInfo; procedure HandleChangeMasterPassword(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>); AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var var
+90 -8
View File
@@ -164,6 +164,10 @@ begin
LObj.AddPair('icon_b64', TJSONNull.Create) LObj.AddPair('icon_b64', TJSONNull.Create)
else else
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString); LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
// Icon provenance so the client knows which icons the automatic
// fetch/clear may touch (0) vs a user upload to preserve (1).
LObj.AddPair('icon_custom',
TJSONBool.Create(LQ.FieldByName('icon_custom').AsInteger = 1));
// Entry kind. Legacy / unset → 'login'. // Entry kind. Legacy / unset → 'login'.
var LKindVal := LQ.FieldByName('kind').AsString; var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login'; if LKindVal = '' then LKindVal := 'login';
@@ -187,6 +191,12 @@ begin
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString); LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
// Echoed so a backup can carry a trashed entry WITH its original
// deletion date: restoring it must not restart the auto-purge clock.
if LQ.FieldByName('deleted_at').IsNull then
LObj.AddPair('deleted_at', TJSONNull.Create)
else
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
if LQ.FieldByName('accessed_at').IsNull then if LQ.FieldByName('accessed_at').IsNull then
LObj.AddPair('accessed_at', TJSONNull.Create) LObj.AddPair('accessed_at', TJSONNull.Create)
else else
@@ -327,8 +337,9 @@ var
LBody, LObj: TJSONObject; LBody, LObj: TJSONObject;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LNow,
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LDeletedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -367,6 +378,11 @@ begin
LCf := LBody.GetValue<string>('custom_fields', ''); LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', ''); LIcon := LBody.GetValue<string>('icon_b64', '');
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
LIconCustom := LBody.GetValue<Boolean>('icon_custom', False);
// Restore flags (import/backup carry them; sync omits them → 0).
LFavorite := LBody.GetValue<Boolean>('favorite', False);
LPinned := LBody.GetValue<Boolean>('pinned', False);
LTemplateEnc := LBody.GetValue<string>('template_enc', ''); LTemplateEnc := LBody.GetValue<string>('template_enc', '');
LTemplateIv := LBody.GetValue<string>('template_iv', ''); LTemplateIv := LBody.GetValue<string>('template_iv', '');
// Caller may bring its own UUID (sync restore / import preserving // Caller may bring its own UUID (sync restore / import preserving
@@ -379,6 +395,13 @@ begin
// (soSave, duplicate) don't send them, so nothing changes there. // (soSave, duplicate) don't send them, so nothing changes there.
LCreatedAt := Trim(LBody.GetValue<string>('created_at', '')); LCreatedAt := Trim(LBody.GetValue<string>('created_at', ''));
LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', '')); LUpdatedAt := Trim(LBody.GetValue<string>('updated_at', ''));
// A backup carries trashed entries too, so a restore rebuilds the trash
// instead of silently dropping it. deleted_at is kept as-is so the
// auto-purge clock resumes where it was rather than restarting.
LDeleted := LBody.GetValue<Boolean>('deleted', False);
LDeletedAt := Trim(LBody.GetValue<string>('deleted_at', ''));
if LDeleted and (LDeletedAt = '') then LDeletedAt := NowUTCStr;
if not LDeleted then LDeletedAt := '';
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -408,10 +431,12 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' + ' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at,' +
' deleted, deleted_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2, :c)'; ' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2, :c,' +
' :del, :delat)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
// site/username/title/tags cleartext columns dropped — only *_enc written. // site/username/title/tags cleartext columns dropped — only *_enc written.
BindNullable(LQ, 'uenc', LUserEnc); BindNullable(LQ, 'uenc', LUserEnc);
@@ -448,6 +473,12 @@ begin
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('ic').DataType := ftMemo; LQ.ParamByName('ic').DataType := ftMemo;
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
else LQ.ParamByName('iconcust').AsInteger := 0;
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
else LQ.ParamByName('fav').AsInteger := 0;
if LPinned then LQ.ParamByName('pin').AsInteger := 1
else LQ.ParamByName('pin').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc); BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv); BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
@@ -455,6 +486,11 @@ begin
if LUpdatedAt = '' then LUpdatedAt := LNow; if LUpdatedAt = '' then LUpdatedAt := LNow;
LQ.ParamByName('c').AsString := LCreatedAt; LQ.ParamByName('c').AsString := LCreatedAt;
LQ.ParamByName('c2').AsString := LUpdatedAt; LQ.ParamByName('c2').AsString := LUpdatedAt;
if LDeleted then LQ.ParamByName('del').AsInteger := 1
else LQ.ParamByName('del').AsInteger := 0;
LQ.ParamByName('delat').DataType := ftString;
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
else LQ.ParamByName('delat').AsString := LDeletedAt;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
@@ -498,6 +534,7 @@ var
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LHasTemplate: Boolean; LHasTemplate: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
LRows: Integer;
begin begin
try try
LUserId := Authenticate(ARequest, AResponse); LUserId := Authenticate(ARequest, AResponse);
@@ -648,6 +685,7 @@ begin
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
LRows := LQ.RowsAffected;
finally finally
LQ.Free; LQ.Free;
end; end;
@@ -655,6 +693,15 @@ begin
DB.Unlock; DB.Unlock;
end; end;
// No row matched: the entry was deleted (another device's sync, a purge)
// between the editor opening it and this save. Reporting 200 here made the
// client show "Saved" for an edit that went nowhere — silent data loss.
if LRows <= 0 then
begin
TJSONHelper.SendError(AResponse, 404, 'Entry no longer exists');
Exit;
end;
LogAudit(LUserId, 'edit_entry', GetClientIP(ARequest)); LogAudit(LUserId, 'edit_entry', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Updated'); TJSONHelper.SendOK(AResponse, 'Updated');
end; end;
@@ -915,6 +962,7 @@ var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LIcon: string; LIcon: string;
LIconCustom: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -934,6 +982,8 @@ begin
LBody := TJSONHelper.ReadBody(ARequest); LBody := TJSONHelper.ReadBody(ARequest);
try try
LIcon := LBody.GetValue<string>('icon_b64', ''); LIcon := LBody.GetValue<string>('icon_b64', '');
// 1 = user upload (preserve from auto fetch/clear), 0 = fetched/cleared.
LIconCustom := LBody.GetValue<Boolean>('custom', False);
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -958,11 +1008,15 @@ begin
// Bump updated_at (UTC) so the icon change wins last-write-wins on // Bump updated_at (UTC) so the icon change wins last-write-wins on
// sync — without it the new icon_b64 rides in the snapshot but other // sync — without it the new icon_b64 rides in the snapshot but other
// devices skip it (timestamp unchanged → "not newer"). // devices skip it (timestamp unchanged → "not newer").
'UPDATE vault_entries SET icon_b64 = :ic, updated_at = datetime(''now'') ' + 'UPDATE vault_entries SET icon_b64 = :ic, icon_custom = :cust, ' +
' updated_at = datetime(''now'') ' +
'WHERE id=:id AND user_id=:uid'; 'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
if LIcon = '' then LQ.ParamByName('ic').Clear if LIcon = '' then LQ.ParamByName('ic').Clear
else LQ.ParamByName('ic').Value := LIcon; else LQ.ParamByName('ic').Value := LIcon;
// A cleared icon (LIcon='') is never "custom" regardless of the flag.
if (LIcon <> '') and LIconCustom then LQ.ParamByName('cust').AsInteger := 1
else LQ.ParamByName('cust').AsInteger := 0;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
@@ -998,7 +1052,10 @@ begin
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries SET icon_b64 = NULL WHERE user_id = :uid'; // Only the auto-fetched cache (icon_custom=0). User uploads (=1) are
// deliberate and survive — removed per entry, never by bulk clear.
'UPDATE vault_entries SET icon_b64 = NULL ' +
'WHERE user_id = :uid AND COALESCE(icon_custom, 0) = 0';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
finally finally
@@ -1187,8 +1244,9 @@ var
LArr, LIds: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec, LSite, LTitle, LUser, LUserEnc, LUserIv, LFolder, LEnc, LIV, LTags, LTotpSec,
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid, LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
LCreatedAt, LUpdatedAt, LCreatedAt, LUpdatedAt, LDeletedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom, LFavorite, LPinned, LDeleted: Boolean;
LQ, LTomb: TFDQuery; LQ, LTomb: TFDQuery;
begin begin
try try
@@ -1245,10 +1303,12 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at) ' + ' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at,' +
' deleted, deleted_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2)'; ' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2,' +
' :del, :delat)';
// Declare optional param types ONCE — the prepared statement is // Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the // reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail // type set before the first .Clear call would otherwise fail
@@ -1270,6 +1330,8 @@ begin
LQ.ParamByName('tiv2').DataType := ftMemo; LQ.ParamByName('tiv2').DataType := ftMemo;
LQ.ParamByName('genc').DataType := ftMemo; LQ.ParamByName('genc').DataType := ftMemo;
LQ.ParamByName('giv').DataType := ftMemo; LQ.ParamByName('giv').DataType := ftMemo;
// Same reason: a live entry clears it, so the type must be known.
LQ.ParamByName('delat').DataType := ftString;
for I := 0 to LArr.Count - 1 do for I := 0 to LArr.Count - 1 do
begin begin
@@ -1296,6 +1358,9 @@ begin
LCf := LEntry.GetValue<string>('custom_fields', ''); LCf := LEntry.GetValue<string>('custom_fields', '');
LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', ''); LIcon := LEntry.GetValue<string>('icon_b64', '');
LIconCustom := LEntry.GetValue<Boolean>('icon_custom', False);
LFavorite := LEntry.GetValue<Boolean>('favorite', False);
LPinned := LEntry.GetValue<Boolean>('pinned', False);
LTemplateEnc := LEntry.GetValue<string>('template_enc', ''); LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
LTemplateIv := LEntry.GetValue<string>('template_iv', ''); LTemplateIv := LEntry.GetValue<string>('template_iv', '');
LUuid := Trim(LEntry.GetValue<string>('uuid', '')); LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
@@ -1306,6 +1371,13 @@ begin
if LCreatedAt = '' then LCreatedAt := LNow; if LCreatedAt = '' then LCreatedAt := LNow;
LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', '')); LUpdatedAt := Trim(LEntry.GetValue<string>('updated_at', ''));
if LUpdatedAt = '' then LUpdatedAt := LNow; if LUpdatedAt = '' then LUpdatedAt := LNow;
// Trashed entries travel in backups so a restore rebuilds the
// trash. Their deletion date is preserved — restarting it would
// give every restored entry a fresh auto-purge delay.
LDeleted := LEntry.GetValue<Boolean>('deleted', False);
LDeletedAt := Trim(LEntry.GetValue<string>('deleted_at', ''));
if LDeleted and (LDeletedAt = '') then LDeletedAt := LNow;
if not LDeleted then LDeletedAt := '';
// Ciphertext is always required. Site is required only for // Ciphertext is always required. Site is required only for
// logins — notes legitimately have no site (their body lives // logins — notes legitimately have no site (their body lives
@@ -1339,11 +1411,21 @@ begin
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf; if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
else LQ.ParamByName('iconcust').AsInteger := 0;
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
else LQ.ParamByName('fav').AsInteger := 0;
if LPinned then LQ.ParamByName('pin').AsInteger := 1
else LQ.ParamByName('pin').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc); BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv); BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
LQ.ParamByName('c').AsString := LCreatedAt; LQ.ParamByName('c').AsString := LCreatedAt;
LQ.ParamByName('c2').AsString := LUpdatedAt; LQ.ParamByName('c2').AsString := LUpdatedAt;
if LDeleted then LQ.ParamByName('del').AsInteger := 1
else LQ.ParamByName('del').AsInteger := 0;
if LDeletedAt = '' then LQ.ParamByName('delat').Clear
else LQ.ParamByName('delat').AsString := LDeletedAt;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
LIds.AddElement(TJSONNumber.Create(LNewId)); LIds.AddElement(TJSONNumber.Create(LNewId));
@@ -82,11 +82,15 @@ begin
if AVerifier <> '' then if AVerifier <> '' then
begin begin
// ZK: client computed PBKDF2 hex locally. Server only does the wrap. // ZK: client computed the verifier locally. Server only wraps it — legacy
// pbkdf2 stores it raw, EVERY other algo (pbkdf2-sha256, pbkdf2-sha256-v2,
// argon2id-v2) is SHA256-wrapped identically. Mirrors VerifierToStoredHash
// in PM.Handler.Auth — keep in sync. The old `else if =CURRENT` silently
// rejected -v2 / argon2id accounts (the current default) as wrong-password.
if Length(AVerifier) <> 64 then Exit; if Length(AVerifier) <> 64 then Exit;
if SameText(LAlgo, HASH_ALGO_LEGACY) then if SameText(LAlgo, HASH_ALGO_LEGACY) then
Result := ConstantTimeEquals(AVerifier, LStoredHash) Result := ConstantTimeEquals(AVerifier, LStoredHash)
else if SameText(LAlgo, HASH_ALGO_CURRENT) then else
Result := ConstantTimeEquals(SHA256Hex(AVerifier), LStoredHash); Result := ConstantTimeEquals(SHA256Hex(AVerifier), LStoredHash);
Exit; Exit;
end; end;
@@ -275,6 +279,8 @@ var
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv, LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
LSalt, LToken, LCSRF, LAlgo: string; LSalt, LToken, LCSRF, LAlgo: string;
LUserId, LKdfIters, LCurrentUses, LNewUses: Integer; LUserId, LKdfIters, LCurrentUses, LNewUses: Integer;
LArgM, LArgT, LArgP: Integer;
LArg: TJSONObject;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
LIP := GetClientIP(ARequest); LIP := GetClientIP(ARequest);
@@ -310,6 +316,7 @@ begin
// Join to users to look up by username + verify the code in one shot. // Join to users to look up by username + verify the code in one shot.
LQ.SQL.Text := LQ.SQL.Text :=
'SELECT u.id, u.salt, u.kdf_iterations, u.hash_algo, ' + 'SELECT u.id, u.salt, u.kdf_iterations, u.hash_algo, ' +
' u.argon2_m, u.argon2_t, u.argon2_p, ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' + ' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' +
'FROM users u ' + 'FROM users u ' +
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' + 'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
@@ -327,6 +334,9 @@ begin
LSalt := LQ.FieldByName('salt').AsString; LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LAlgo := LQ.FieldByName('hash_algo').AsString; LAlgo := LQ.FieldByName('hash_algo').AsString;
LArgM := LQ.FieldByName('argon2_m').AsInteger;
LArgT := LQ.FieldByName('argon2_t').AsInteger;
LArgP := LQ.FieldByName('argon2_p').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString; LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString; LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString; LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
@@ -403,6 +413,18 @@ begin
LObj.AddPair('salt', LSalt); LObj.AddPair('salt', LSalt);
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
LObj.AddPair('hashAlgo', LAlgo); LObj.AddPair('hashAlgo', LAlgo);
// Echo the Argon2 params (as /login/challenge does) so the client derives
// under the account's real params, not the default fallback. Harmless today
// (all accounts use the default) but avoids a silent wrong-key derivation if
// per-account params are ever tuned. Omitted for non-Argon2 accounts (m=0).
if LArgM > 0 then
begin
LArg := TJSONObject.Create;
LArg.AddPair('m', TJSONNumber.Create(LArgM));
LArg.AddPair('t', TJSONNumber.Create(LArgT));
LArg.AddPair('p', TJSONNumber.Create(LArgP));
LObj.AddPair('argon2', LArg);
end;
LObj.AddPair('wrappedKey', LWrappedKey); LObj.AddPair('wrappedKey', LWrappedKey);
LObj.AddPair('wrappedIv', LWrappedIv); LObj.AddPair('wrappedIv', LWrappedIv);
LObj.AddPair('kdfSalt', LKdfSalt); LObj.AddPair('kdfSalt', LKdfSalt);
+11 -1
View File
@@ -1,6 +1,7 @@
program PMServer; program PMServer;
uses uses
System.SysUtils, // FindCmdLineSwitch
System.StartUpCopy, System.StartUpCopy,
FMX.Forms, FMX.Forms,
PM.SingleInstance in 'Source\PM.SingleInstance.pas', PM.SingleInstance in 'Source\PM.SingleInstance.pas',
@@ -19,6 +20,8 @@ uses
PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.QuickUnlock in 'Source\PM.QuickUnlock.pas',
PM.PinUnlock in 'Source\PM.PinUnlock.pas', PM.PinUnlock in 'Source\PM.PinUnlock.pas',
PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.MachineId in 'Source\PM.MachineId.pas',
PM.Licence in 'Source\PM.Licence.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas', PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.Favicon in 'Source\PM.Favicon.pas', PM.Favicon in 'Source\PM.Favicon.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas', PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
@@ -43,6 +46,13 @@ begin
Exit; Exit;
Application.Initialize; Application.Initialize;
// --tray ("Start with Windows"): FMX puts its taskbar proxy on screen before
// FormCreate is ever reached — measured — so the watcher has to start here,
// before the form is built. It only touches raw Win32 (FindWindowEx +
// ShowWindow), never an FMX object: an earlier attempt that poked
// MainForm.Visible/Handle at this point died with an access violation.
if FindCmdLineSwitch('tray', True) then
StartTrayProxyHider;
Application.CreateForm(TMainForm, MainForm); Application.CreateForm(TMainForm, MainForm);
Application.Run; Application.Run;
end. end.
+2
View File
@@ -227,6 +227,8 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.QuickUnlock.pas"/> <DCCReference Include="Source\PM.QuickUnlock.pas"/>
<DCCReference Include="Source\PM.PinUnlock.pas"/> <DCCReference Include="Source\PM.PinUnlock.pas"/>
<DCCReference Include="Source\PM.UserPrefs.pas"/> <DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.MachineId.pas"/>
<DCCReference Include="Source\PM.Licence.pas"/>
<DCCReference Include="Source\PM.AutoStart.pas"/> <DCCReference Include="Source\PM.AutoStart.pas"/>
<DCCReference Include="Source\PM.Favicon.pas"/> <DCCReference Include="Source\PM.Favicon.pas"/>
<DCCReference Include="Source\PM.ProcessLockdown.pas"/> <DCCReference Include="Source\PM.ProcessLockdown.pas"/>
File diff suppressed because it is too large Load Diff
+11
View File
@@ -83,6 +83,12 @@ begin
FConn.Params.Values['Synchronous'] := 'Normal'; FConn.Params.Values['Synchronous'] := 'Normal';
FConn.Params.Values['BusyTimeout'] := '5000'; FConn.Params.Values['BusyTimeout'] := '5000';
FConn.Params.Values['JournalMode'] := 'WAL'; FConn.Params.Values['JournalMode'] := 'WAL';
// Explicit ON: SQLite defaults foreign_keys OFF per-connection, and the
// schema relies on ON DELETE CASCADE (entry_attachments, entries_password_
// history, recovery_keys on user/entry delete). FireDAC is *said* to default
// this On but it's undocumented for us — set it so a permanent delete never
// leaves orphaned encrypted blobs. No-op if it was already On (§6c-9).
FConn.Params.Values['ForeignKeys'] := 'On';
FConn.Open; FConn.Open;
CreateSchema; CreateSchema;
ApplyMigrations; ApplyMigrations;
@@ -379,6 +385,11 @@ begin
// Fetched on demand by the Delphi favicon proxy when the user opts in. // Fetched on demand by the Delphi favicon proxy when the user opts in.
// NULL = no icon cached → JS falls back to the first-letter avatar. // NULL = no icon cached → JS falls back to the first-letter avatar.
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT'); AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
// Icon provenance: 0 = auto-fetched favicon (part of the disposable cache),
// 1 = user-uploaded custom icon. Clear-cache and Re-fetch-all only touch
// icon_custom=0 so a deliberate upload is never clobbered by an automatic
// action — only the user changes/removes it, per entry.
AddColumnIfMissing('vault_entries', 'icon_custom', 'INTEGER');
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers // Tracked client-side via POST /entries/:id/touch on copy/open. Powers
// the sidebar "Recent" view. NULL = never accessed since the column // the sidebar "Recent" view. NULL = never accessed since the column
// landed (legacy rows). // landed (legacy rows).
+94 -7
View File
@@ -36,7 +36,78 @@ function TryServeEmbedded(ARequest: TIdHTTPRequestInfo;
implementation implementation
uses uses
Winapi.Windows; Winapi.Windows, System.Hash;
// ===== Brouillage des blocs embarques ========================================
//
// CE N'EST PAS DU SECRET, C'EST DE L'OPACITE, et il faut le dire pour ne pas
// s'en croire protege. La cle est forcement dans l'exe, a quelques centaines
// d'octets d'ici : quiconque tient le binaire peut retrouver le texte. Ce que
// ca supprime, c'est l'onglet "Image" de Process Explorer et n'importe quel
// `strings` — le frontend n'est plus lisible en survolant le fichier. L'onglet
// "Memory", un debogueur, ou simplement la reponse HTTP le rendent toujours :
// WebView2 doit recevoir du JavaScript en clair pour l'executer.
//
// D'ou le choix d'un flux XOR plutot que d'AES : a securite rigoureusement
// egale (nulle, contre qui a l'exe), c'est dix lignes au lieu d'un appel CNG,
// et surtout ca n'affiche pas une force qu'on n'a pas.
//
// Le flux depend du NOM DE LA RESSOURCE : deux fichiers qui commencent
// pareil — et ils commencent tous par les memes lignes de commentaire — ne
// produisent pas la meme sortie.
const
// Doit correspondre EXACTEMENT a $ObfSecret dans BuildAssets.ps1.
// Les deux se desaccordent = magie invalide = 404 sur /index.html.
AssetObfSecret: array[0..31] of Byte = (
$9A, $AC, $C6, $05, $DE, $D6, $FA, $E7,
$4F, $95, $04, $E4, $2C, $1D, $DF, $26,
$9B, $BA, $C9, $D2, $0A, $52, $D5, $90,
$B1, $5E, $A8, $09, $12, $9B, $9E, $6C
);
// Prefixe le clair AVANT brouillage. Sans lui, une cle qui a derive entre le
// script et l'exe servirait du binaire au navigateur : ecran blanc, aucune
// trace. Avec lui, on sait refuser et le dire.
AssetMagic = 'PMA1';
// Le XOR est son propre inverse : la meme fonction brouille et debrouille.
// C'est BuildAssets.ps1 qui tient l'autre moitie, et les deux doivent produire
// le meme flux — d'ou le detail du format, ecrit ici une fois pour toutes :
//
// bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
//
function DeobfuscateAsset(const AResName: string; const AData: TBytes): TBytes;
var
LSeed, LName, LBlock: TBytes;
LSha: THashSHA2;
LIdx: Cardinal;
I, J: Integer;
begin
SetLength(Result, Length(AData));
LName := TEncoding.UTF8.GetBytes(AResName);
SetLength(LSeed, 32 + Length(LName));
Move(AssetObfSecret[0], LSeed[0], 32);
if Length(LName) > 0 then
Move(LName[0], LSeed[32], Length(LName));
LIdx := 0;
I := 0;
while I < Length(AData) do
begin
LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
LSha.Update(LSeed);
LSha.Update(LIdx, SizeOf(LIdx));
LBlock := LSha.HashAsBytes;
for J := 0 to High(LBlock) do
begin
if I + J >= Length(AData) then Break;
Result[I + J] := AData[I + J] xor LBlock[J];
end;
Inc(I, Length(LBlock));
Inc(LIdx);
end;
end;
// The manifest is auto-generated. A stub is checked in so the project // The manifest is auto-generated. A stub is checked in so the project
// compiles before BuildAssets.ps1 ever runs; running the script overwrites // compiles before BuildAssets.ps1 ever runs; running the script overwrites
@@ -86,28 +157,44 @@ var
LResName, LExt: string; LResName, LExt: string;
LStream: TResourceStream; LStream: TResourceStream;
LMS: TMemoryStream; LMS: TMemoryStream;
LRaw, LPlain: TBytes;
begin begin
Result := False; Result := False;
if not SameText(ARequest.Command, 'GET') then Exit; if not SameText(ARequest.Command, 'GET') then Exit;
if not FindResourceFor(ARequest.Document, LResName) then Exit; if not FindResourceFor(ARequest.Document, LResName) then Exit;
if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit; if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit;
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
try
SetLength(LRaw, LStream.Size);
if LStream.Size > 0 then
LStream.ReadBuffer(LRaw[0], LStream.Size);
finally
LStream.Free;
end;
LPlain := DeobfuscateAsset(LResName, LRaw);
// La magie tombe = le script et l'exe n'ont pas la meme cle. Refuser vaut
// mieux que servir du binaire : un 404 se voit dans le journal, un ecran
// blanc ne dit rien. Symptome a reconnaitre : /index.html en 404 juste apres
// avoir touche AssetObfSecret d'un seul cote.
if (Length(LPlain) < Length(AssetMagic)) or
(TEncoding.ANSI.GetString(Copy(LPlain, 0, Length(AssetMagic))) <> AssetMagic) then
Exit(False);
LExt := ExtractFileExt(ARequest.Document); LExt := ExtractFileExt(ARequest.Document);
if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then
LExt := '.html'; LExt := '.html';
AResponse.ContentType := MimeTypeFor(LExt); AResponse.ContentType := MimeTypeFor(LExt);
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
try
// Copy into a TMemoryStream so Indy can own and free it after the response. // Copy into a TMemoryStream so Indy can own and free it after the response.
LMS := TMemoryStream.Create; LMS := TMemoryStream.Create;
LMS.CopyFrom(LStream, 0); if Length(LPlain) > Length(AssetMagic) then
LMS.WriteBuffer(LPlain[Length(AssetMagic)], Length(LPlain) - Length(AssetMagic));
LMS.Position := 0; LMS.Position := 0;
AResponse.ContentStream := LMS; AResponse.ContentStream := LMS;
AResponse.FreeContentStream := True; AResponse.FreeContentStream := True;
finally
LStream.Free;
end;
AResponse.ResponseNo := 200; AResponse.ResponseNo := 200;
Result := True; Result := True;
end; end;
+53 -3
View File
@@ -117,10 +117,31 @@ procedure TPMHTTPServer.Start(APort: Integer; SameFolder: Boolean;
const const
EphemeralPortMin = 49152; EphemeralPortMin = 49152;
EphemeralPortMax = 65535; EphemeralPortMax = 65535;
// Trois essais : le port demande, puis deux replis. Au-dela, ce n'est plus
// un port occupe mais une pile reseau qui ne veut pas de nous, et insister
// ne ferait que retarder le message d'erreur.
BindAttempts = 3;
/// Un port ephemere tire du generateur cryptographique de Windows.
///
/// PAS Random() : Randomize n'est appele nulle part dans ce programme, donc
/// Random rend la MEME suite a chaque lancement - le port "aleatoire" de
/// `port=0` etait en realite le meme tous les jours, ce qui vide de son sens
/// l'aleatoire cense compliquer un balayage exterieur. RandomBytes passe par
/// BCryptGenRandom, deja utilise pour le jeton d'acces.
function PickEphemeralPort: Integer;
var
LRaw: TBytes;
begin
LRaw := PM.Crypto.RandomBytes(2);
Result := EphemeralPortMin +
((LRaw[0] shl 8 or LRaw[1]) mod (EphemeralPortMax - EphemeralPortMin + 1));
end;
var var
LBinding: TIdSocketHandle; LBinding: TIdSocketHandle;
LDBPath, LWebRoot, LResolvedPort: string; LDBPath, LWebRoot, LResolvedPort: string;
LRequestedPort: Integer; LRequestedPort, LAttempt: Integer;
begin begin
if FServer.Active then Exit; if FServer.Active then Exit;
@@ -133,7 +154,7 @@ begin
LRequestedPort := APort; LRequestedPort := APort;
if LRequestedPort = 0 then if LRequestedPort = 0 then
LRequestedPort := EphemeralPortMin + Random(EphemeralPortMax - EphemeralPortMin); LRequestedPort := PickEphemeralPort;
var pathParent := '..\'; var pathParent := '..\';
if SameFolder then if SameFolder then
@@ -146,12 +167,41 @@ begin
Log('Web root: ' + LWebRoot); Log('Web root: ' + LWebRoot);
InitStaticServer(LWebRoot); InitStaticServer(LWebRoot);
// SE LIER EST LE TEST, ET IL N'Y EN A PAS D'AUTRE QUI VAILLE.
//
// On pourrait sonder le port avant, pour voir si quelqu'un repond. Ce serait
// a la fois plus faible et plus fragile : un service qui ecoute sans repondre
// passerait inapercu, et le port peut etre pris entre le sondage et le lien.
// Tenter le lien tranche pour de bon, sans course.
//
// Le PREMIER essai honore le port demande - celui de config.txt, que
// l'utilisateur a peut-etre choisi expres. Les suivants prennent un port
// ephemere, exactement ce que fait un lancement sans port configure. Et on le
// DIT a chaque fois : un serveur qui deplace son port en silence est pire
// qu'un serveur qui refuse de demarrer, parce que rien n'explique ensuite
// pourquoi une adresse notee quelque part ne repond plus.
for LAttempt := 1 to BindAttempts do
begin
FServer.Bindings.Clear; FServer.Bindings.Clear;
LBinding := FServer.Bindings.Add; LBinding := FServer.Bindings.Add;
LBinding.IP := '127.0.0.1'; LBinding.IP := '127.0.0.1';
LBinding.Port := LRequestedPort; LBinding.Port := LRequestedPort;
try
FServer.Active := True; FServer.Active := True;
Break;
except
on E: Exception do
begin
FServer.Active := False; // Indy peut laisser le lien a moitie ouvert
Log(Format('Port %d unavailable: %s', [LRequestedPort, E.Message]));
// Le dernier echec remonte tel quel : l'appelant sait deja l'afficher.
if LAttempt = BindAttempts then raise;
LRequestedPort := PickEphemeralPort;
Log(Format(' falling back to port %d (attempt %d of %d)',
[LRequestedPort, LAttempt + 1, BindAttempts]));
end;
end;
end;
FBoundPort := LRequestedPort; FBoundPort := LRequestedPort;
LResolvedPort := IntToStr(FBoundPort); LResolvedPort := IntToStr(FBoundPort);
Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)', Log(Format('Server started on http://127.0.0.1:%s (token:%s process_check:%s)',
+371
View File
@@ -0,0 +1,371 @@
unit PM.Licence;
{
Verification hors ligne d'une licence signee ECDSA P-256.
Une licence est une chaine "charge-utile.signature", chacune en base64url,
fabriquee par tools\licence.ps1 avec la cle privee qui reste chez l'editeur.
La cle PUBLIQUE seule est ici, en dur : elle ne permet que de verifier.
CE QUE CA ARRETE, ET CE QUE CA N'ARRETE PAS. Sans la cle privee, personne ne
fabrique de licence valable — c'est tout ce qu'un schema local peut promettre.
Rien n'empeche de rapiecer l'exe pour sauter l'appel : c'est vrai de tous les
schemas hors ligne, y compris ceux qui se vendent cher. La seule parade reelle
serait de deporter une fonction sur un serveur, ce qui contredit le "100 %
hors ligne" de cette application.
POURQUOI AUCUN ASN.1 NULLE PART. .NET sur Windows EST CNG : la signature que
produit le script (r||s brut, 64 octets) est deja celle qu'attend
BCryptVerifySignature, et les coordonnees X/Y exportees se rangent telles
quelles dans un BCRYPT_ECCPUBLIC_BLOB. Le decodage DER, moitie penible de
l'exercice d'habitude, n'existe pas ici.
La licence acceptee est rangee en pref DPAPI (PM.UserPrefs), donc liee au
compte Windows et survivant au changement de port comme le reste.
}
interface
uses
System.SysUtils, System.Classes;
type
TLicenceInfo = record
Holder: string; // nom affichable
Email: string;
IssuedOn: string; // 'yyyy-mm-dd', date d'emission (PAS une expiration)
Machine: string; // empreinte du poste, vide = licence flottante
end;
// Verifie une chaine de licence sans rien ecrire. False = signature invalide,
// charge utile trafiquee, format casse, ou cle publique pas encore posee.
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
// Verifie puis range la licence si elle tient. C'est le point d'entree du
// bouton "Activer".
function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
// Etat courant : relit la licence rangee et la REVERIFIE. Ne se contente
// jamais d'un drapeau booleen stocke — un drapeau se retourne, une signature
// se refabrique seulement avec la cle privee.
function IsLicensed: Boolean;
function LicenceInfo: TLicenceInfo;
procedure ClearLicence;
// L'empreinte de CE poste, a montrer sur l'ecran d'activation : sans elle
// l'utilisateur n'a rien a envoyer pour qu'on lui emette une licence liee.
function ThisMachineId: string;
// Verifie le chemin complet contre un vecteur de test fige. Appele a
// l'initialisation de l'unite, comme SelfTestCrypto.
procedure SelfTestLicence;
implementation
uses
System.JSON, System.Hash, System.NetEncoding, Winapi.Windows,
PM.UserPrefs, PM.MachineId;
const
LicencePrefKey = 'licenceKey';
// ===== LA CLE PUBLIQUE DE L'EDITEUR ========================================
//
// BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32).
//
// Posee le 2026-09-05, REFAITE le meme jour : la premiere privee a ete
// supprimee sans copie. Une paire perdue interdit toute nouvelle licence a
// jamais, et la seule reparation est celle-ci — regenerer et reemettre.
// C'etait sans douleur avec une licence en circulation ; ca ne le sera plus.
//
// NE JAMAIS REGENERER SANS RAISON : toutes les licences deja emises
// tomberaient d'un coup. Un blob a zero refuse tout, ce qui est le bon
// defaut — un defaut qui accepterait quelque chose serait pire.
LicencePublicKeyBlob: array[0..71] of Byte = (
$45, $43, $53, $31, $20, $00, $00, $00, $E3, $7A, $24, $23,
$B3, $90, $E4, $5C, $02, $7F, $49, $17, $9F, $1E, $BA, $96,
$18, $54, $BF, $9A, $F4, $42, $E9, $1E, $A4, $AD, $32, $DF,
$82, $7E, $54, $F4, $E7, $04, $5E, $FE, $84, $AA, $FA, $A3,
$3F, $78, $26, $6E, $C3, $67, $8A, $2A, $F2, $B4, $CD, $0D,
$EA, $2E, $96, $CB, $99, $71, $70, $DC, $E7, $C5, $40, $E7
);
// ===== CNG ===================================================================
const
STATUS_SUCCESS = NTSTATUS(0);
BCRYPT_ECDSA_P256 = 'ECDSA_P256';
BCRYPT_ECCPUBLIC = 'ECCPUBLICBLOB';
EccBlobLen = 72;
P256SignatureLen = 64; // r||s, 32 octets chacun
function BCryptOpenAlgorithmProvider(out phAlgorithm: Pointer;
pszAlgId, pszImplementation: PWideChar; dwFlags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll' name 'BCryptOpenAlgorithmProvider';
function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer;
dwFlags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll' name 'BCryptCloseAlgorithmProvider';
function BCryptImportKeyPair(hAlgorithm, hImportKey: Pointer;
pszBlobType: PWideChar; out phKey: Pointer; pbInput: PByte;
cbInput, dwFlags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll' name 'BCryptImportKeyPair';
function BCryptDestroyKey(hKey: Pointer): NTSTATUS; stdcall;
external 'bcrypt.dll' name 'BCryptDestroyKey';
function BCryptVerifySignature(hKey, pPaddingInfo: Pointer;
pbHash: PByte; cbHash: ULONG; pbSignature: PByte;
cbSignature, dwFlags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll' name 'BCryptVerifySignature';
// ===== Outils ================================================================
function Base64UrlDecode(const AText: string): TBytes;
var
LStd: string;
begin
LStd := StringReplace(AText, '-', '+', [rfReplaceAll]);
LStd := StringReplace(LStd, '_', '/', [rfReplaceAll]);
while (Length(LStd) mod 4) <> 0 do
LStd := LStd + '=';
try
Result := TNetEncoding.Base64.DecodeStringToBytes(LStd);
except
// Une chaine recopiee de travers n'est pas une panne : c'est un refus.
Result := nil;
end;
end;
// Verifie contre UNE cle publique donnee. La production passe la constante
// ci-dessus ; l'auto-test passe la sienne, ce qui evite de figer un second
// jeu de constantes pour la meme mecanique.
function VerifyAgainstKey(const AKeyBlob: TBytes; const AMessage,
ASignature: TBytes): Boolean;
var
LAlg, LKey: Pointer;
LHash: TBytes;
LSha: THashSHA2;
begin
Result := False;
if (Length(AKeyBlob) <> EccBlobLen) or
(Length(ASignature) <> P256SignatureLen) or (Length(AMessage) = 0) then
Exit;
if BCryptOpenAlgorithmProvider(LAlg, BCRYPT_ECDSA_P256, nil, 0) <> STATUS_SUCCESS then
Exit;
try
if BCryptImportKeyPair(LAlg, nil, BCRYPT_ECCPUBLIC, LKey,
@AKeyBlob[0], EccBlobLen, 0) <> STATUS_SUCCESS then
Exit;
try
// BCryptVerifySignature attend l'EMPREINTE, pas le message.
LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
LSha.Update(AMessage);
LHash := LSha.HashAsBytes;
Result := BCryptVerifySignature(LKey, nil, @LHash[0], Length(LHash),
@ASignature[0], P256SignatureLen, 0) = STATUS_SUCCESS;
finally
BCryptDestroyKey(LKey);
end;
finally
BCryptCloseAlgorithmProvider(LAlg, 0);
end;
end;
function ParsePayload(const APayload: TBytes; out AInfo: TLicenceInfo): Boolean;
var
LVal: TJSONValue;
LObj: TJSONObject;
begin
Result := False;
AInfo := Default(TLicenceInfo);
LVal := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(APayload));
if LVal = nil then
Exit;
try
// Un transtypage direct leverait sur une charge utile qui n'est pas un
// objet, et fuirait la valeur au passage.
if not (LVal is TJSONObject) then
Exit;
LObj := TJSONObject(LVal);
AInfo.Holder := LObj.GetValue<string>('n', '');
AInfo.Email := LObj.GetValue<string>('e', '');
AInfo.IssuedOn := LObj.GetValue<string>('d', '');
AInfo.Machine := LowerCase(LObj.GetValue<string>('m', ''));
Result := AInfo.Email <> '';
finally
LVal.Free;
end;
end;
function SplitAndVerify(const AKeyBlob: TBytes; const ALicence: string;
out AInfo: TLicenceInfo): Boolean;
var
LDot: Integer;
LMsg, LSig: TBytes;
begin
Result := False;
AInfo := Default(TLicenceInfo);
LDot := Pos('.', ALicence);
if (LDot < 2) or (LDot = Length(ALicence)) then
Exit;
LMsg := Base64UrlDecode(Copy(ALicence, 1, LDot - 1));
LSig := Base64UrlDecode(Copy(ALicence, LDot + 1, MaxInt));
if not VerifyAgainstKey(AKeyBlob, LMsg, LSig) then
Exit;
// La signature d'abord, la lecture ensuite : on n'analyse jamais une charge
// utile dont on ne sait pas encore qu'elle vient de nous.
Result := ParsePayload(LMsg, AInfo);
end;
// Le liage au poste, verifie APRES la signature — l'empreinte n'a de sens que
// dans une charge utile dont on sait deja qu'elle vient de nous.
//
// UNE EMPREINTE ABSENTE EST REFUSEE. Les licences flottantes ont existe le
// temps d'une seance, et l'une d'elles est sortie ; les refuser est la SEULE
// facon de l'annuler. Un schema hors ligne n'a pas de revocation — rien ne
// peut aller dire a un exe deja installe qu'une cle ne vaut plus — donc la
// seule prise qui reste est de cesser d'accepter toute une classe de cles.
//
// Ce que ca coute : plus de licence de site, une cle par poste. Assume.
//
// Consequence de bord : un poste dont l'empreinte vaut 'unavailable' (ni WMI
// ni MachineGuid) ne peut plus etre licencie du tout, puisque le script
// d'emission n'accepte que 16 caracteres hexadecimaux. C'est le bon sens de
// l'erreur : mieux vaut un poste qu'on ne peut pas licencier qu'une empreinte
// degeneree qui ouvrirait partout.
function MachineMatches(const AInfo: TLicenceInfo): Boolean;
begin
Result := (AInfo.Machine <> '') and
SameText(AInfo.Machine, PM.MachineId.MachineFingerprint);
end;
// ===== API ===================================================================
var
CachedChecked: Boolean = False;
CachedValid: Boolean = False;
CachedInfo: TLicenceInfo;
function ProductionKey: TBytes;
begin
SetLength(Result, EccBlobLen);
Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen);
end;
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
begin
Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo) and
MachineMatches(AInfo);
end;
function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
begin
Result := VerifyLicence(ALicence, AInfo);
if Result then
begin
SetPref(LicencePrefKey, Trim(ALicence));
CachedChecked := True;
CachedValid := True;
CachedInfo := AInfo;
end;
end;
procedure Refresh;
var
LStored: string;
begin
if CachedChecked then
Exit;
CachedChecked := True;
LStored := GetPref(LicencePrefKey);
CachedValid := (LStored <> '') and VerifyLicence(LStored, CachedInfo);
if not CachedValid then
CachedInfo := Default(TLicenceInfo);
end;
function IsLicensed: Boolean;
begin
Refresh;
Result := CachedValid;
end;
function LicenceInfo: TLicenceInfo;
begin
Refresh;
Result := CachedInfo;
end;
function ThisMachineId: string;
begin
Result := PM.MachineId.MachineFingerprintDisplay;
end;
procedure ClearLicence;
begin
SetPref(LicencePrefKey, '');
CachedChecked := True;
CachedValid := False;
CachedInfo := Default(TLicenceInfo);
end;
// ===== Auto-test =============================================================
procedure SelfTestLicence;
const
// Paire de test, sans rapport avec la cle de production : sa privee a ete
// jetee. Elle ne prouve qu'une chose, mais elle la prouve entierement — que
// le blob, l'empreinte et le format r||s s'accordent avec CNG.
TestKey: array[0..71] of Byte = (
$45, $43, $53, $31, $20, $00, $00, $00, $27, $24, $81, $FF,
$7E, $21, $27, $90, $91, $3D, $B7, $7E, $6E, $20, $18, $8B,
$48, $51, $D2, $DD, $1A, $BB, $3D, $E8, $65, $06, $55, $AD,
$A3, $93, $EE, $6D, $D4, $56, $42, $9D, $F7, $85, $3C, $C9,
$98, $FA, $42, $43, $47, $E6, $2F, $2B, $9B, $30, $6B, $15,
$88, $AA, $2D, $C3, $79, $2B, $1E, $A2, $4D, $1F, $E3, $55
);
TestLicence = 'eyJuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' +
'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA';
var
LKey: TBytes;
LInfo: TLicenceInfo;
LTampered: string;
begin
SetLength(LKey, EccBlobLen);
Move(TestKey[0], LKey[0], EccBlobLen);
if not SplitAndVerify(LKey, TestLicence, LInfo) then
raise Exception.Create('Auto-test licence ECHOUE : une licence valable est refusee.');
if LInfo.Email <> 'ami@exemple.com' then
raise Exception.CreateFmt(
'Auto-test licence ECHOUE : charge utile mal lue (got "%s").', [LInfo.Email]);
// Un octet retourne dans la charge utile doit faire tomber la signature.
LTampered := 'e0JuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' +
'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA';
if SplitAndVerify(LKey, LTampered, LInfo) then
raise Exception.Create('Auto-test licence ECHOUE : une licence trafiquee est acceptee.');
// Le liage au poste, eprouve sur l'enregistrement : on ne peut pas signer un
// vecteur lie sans la cle privee de test, jetee — mais c'est MachineMatches
// qui porte la regle, et c'est lui qu'il faut voir tomber.
LInfo.Machine := '';
if MachineMatches(LInfo) then
raise Exception.Create('Auto-test licence ECHOUE : une licence flottante est acceptee.');
LInfo.Machine := 'ffffffffffffffff';
if MachineMatches(LInfo) then
raise Exception.Create('Auto-test licence ECHOUE : une licence d''un AUTRE poste est acceptee.');
LInfo.Machine := PM.MachineId.MachineFingerprint;
if not MachineMatches(LInfo) then
raise Exception.Create('Auto-test licence ECHOUE : la licence de CE poste est refusee.');
end;
initialization
SelfTestLicence;
end.
+162
View File
@@ -0,0 +1,162 @@
unit PM.MachineId;
{
Empreinte de la machine : carte mere + disque systeme.
Sert a lier une licence a UN poste (PM.Licence). Deux sources, melangees puis
hachees, dont on ne publie que 16 caracteres hexadecimaux :
- UUID de la carte mere, lu dans le SMBIOS via Win32_ComputerSystemProduct.
- Numero de serie du volume systeme (GetVolumeInformation).
POURQUOI HACHER PLUTOT QUE CONCATENER. L'empreinte se recopie dans un
courriel pour demander une licence. L'UUID brut de la carte mere est un
identifiant materiel stable et mondialement unique : il n'a pas a circuler
en clair, ni a se retrouver dans nos journaux. Le condense suffit a comparer,
et ne se remonte pas.
CE QUI LA CHANGE, ET DONC CASSE LA LICENCE : remplacer la carte mere,
remplacer le disque systeme, reinstaller Windows sur un autre volume, ou
cloner vers une autre machine. C'est le prix assume du liage — la reparation
est une reemission, pas un correctif.
QUAND LE SMBIOS MENT. Beaucoup de machines virtuelles et d'images clonees
annoncent un UUID nul ou tout a FF. S'en contenter ferait tomber des postes
differents sur la MEME empreinte, et une licence emise pour l'un ouvrirait
chez l'autre. On retombe alors sur MachineGuid, pose par Windows a
l'installation et distinct sur chaque installation.
}
interface
// 16 caracteres hexadecimaux, minuscules. Calcule une fois, puis retenu :
// l'appel WMI coute quelques centaines de millisecondes.
function MachineFingerprint: string;
// La meme, en groupes de quatre — c'est un code qu'un humain recopie a la
// main dans un courriel, et le decoupage divise les fautes de transcription.
function MachineFingerprintDisplay: string;
implementation
uses
System.SysUtils, System.Win.ComObj, System.Win.Registry, System.Variants,
Winapi.Windows, Winapi.ActiveX, PM.Crypto;
var
CachedPrint: string = '';
function BoardUUID: string;
var
LLocator, LServices, LSet, LItem: OleVariant;
LEnum: IEnumVariant;
LFetched: LongWord;
begin
Result := '';
// COM DOIT ETRE INITIALISE ICI, PAS AILLEURS PAR CHANCE.
//
// Sans ca, l'empreinte depend de qui a demarre avant nous : appelee apres
// l'initialisation de WebView2 elle lit le vrai UUID de la carte mere,
// appelee avant elle echoue et retombe sur MachineGuid — DEUX empreintes
// differentes pour une seule machine, et une licence liee a l'une que
// l'autre refuse. Un identifiant qui depend de l'ordre de demarrage n'est
// pas un identifiant.
//
// ET ON NE LIBERE PAS. Un CoUninitialize symetrique parait propre et se
// paie d'une violation d'acces : les mandataires WMI et le cache de
// type-info de ComObj survivent a l'appel et se liberent plus tard, dans un
// appartement qui n'existe plus. Laisser l'appartement ouvert pour la duree
// du processus ne coute rien ici — cette application heberge WebView2, donc
// COM y est de toute facon initialise et le reste.
CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
try
LLocator := CreateOleObject('WbemScripting.SWbemLocator');
LServices := LLocator.ConnectServer('.', 'root\CIMV2');
LSet := LServices.ExecQuery('SELECT UUID FROM Win32_ComputerSystemProduct');
LEnum := IUnknown(LSet._NewEnum) as IEnumVariant;
if LEnum.Next(1, LItem, LFetched) = S_OK then
if not VarIsNull(LItem.UUID) then
Result := UpperCase(Trim(string(LItem.UUID)));
except
// WMI desactive, service arrete, droits refuses : ce n'est pas une panne,
// c'est une source en moins. L'appelant retombe sur MachineGuid.
Result := '';
end;
// Un UUID nul ou tout a FF n'identifie rien : il est partage par toutes les
// machines qui portent le meme defaut de micrologiciel.
if (Result = '00000000-0000-0000-0000-000000000000') or
(Result = 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF') then
Result := '';
end;
function MachineGuid: string;
var
LReg: TRegistry;
begin
Result := '';
LReg := TRegistry.Create(KEY_READ or KEY_WOW64_64KEY);
try
LReg.RootKey := HKEY_LOCAL_MACHINE;
if LReg.OpenKeyReadOnly('SOFTWARE\Microsoft\Cryptography') then
Result := LReg.ReadString('MachineGuid');
except
Result := '';
end;
LReg.Free;
end;
function SystemVolumeSerial: string;
var
LRoot: array[0..MAX_PATH] of Char;
LSerial, LMaxLen, LFlags: DWORD;
begin
Result := '';
if GetWindowsDirectory(LRoot, MAX_PATH) = 0 then Exit;
// "C:\" — la racine du volume qui porte Windows.
var LDrive := Copy(string(LRoot), 1, 3);
LSerial := 0;
if GetVolumeInformation(PChar(LDrive), nil, 0, @LSerial, LMaxLen, LFlags, nil, 0) then
Result := IntToHex(LSerial, 8);
end;
function MachineFingerprint: string;
var
LBoard, LVolume, LSeed: string;
begin
if CachedPrint <> '' then
Exit(CachedPrint);
LBoard := BoardUUID;
if LBoard = '' then
LBoard := MachineGuid;
LVolume := SystemVolumeSerial;
// Les deux sources peuvent manquer sur un poste verrouille a l'extreme.
// Une empreinte vide serait la MEME partout : mieux vaut une chaine qui ne
// correspondra a aucune licence qu'une chaine qui les accepterait toutes.
if (LBoard = '') and (LVolume = '') then
Exit('unavailable');
LSeed := 'pmserver/machine/v1|' + LBoard + '|' + LVolume;
CachedPrint := Copy(PM.Crypto.SHA256Hex(LSeed), 1, 16);
Result := CachedPrint;
end;
function MachineFingerprintDisplay: string;
var
LRaw: string;
I: Integer;
begin
LRaw := UpperCase(MachineFingerprint);
Result := '';
for I := 1 to Length(LRaw) do
begin
if (I > 1) and ((I - 1) mod 4 = 0) then
Result := Result + '-';
Result := Result + LRaw[I];
end;
end;
end.
+18 -5
View File
@@ -86,7 +86,8 @@ begin
LQ.Free; LQ.Free;
end; end;
if (LExpires <> 0) and (LExpires < Now) then // expires_at is now UTC (see CreateSession) — compare against UTC too.
if (LExpires <> 0) and (LExpires < NowUTC) then
begin begin
DeleteSessionByTokenHash(LTokenHash); DeleteSessionByTokenHash(LTokenHash);
TJSONHelper.SendError(AResponse, 401, 'Session expired'); TJSONHelper.SendError(AResponse, 401, 'Session expired');
@@ -100,7 +101,7 @@ end;
procedure RequireCSRF(ARequest: TIdHTTPRequestInfo; procedure RequireCSRF(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; AUserId: Integer); AResponse: TIdHTTPResponseInfo; AUserId: Integer);
var var
LSubmitted, LStored: string; LSubmitted, LStored, LTokenHash: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
if SameText(ARequest.Command, 'GET') then Exit; if SameText(ARequest.Command, 'GET') then Exit;
@@ -112,6 +113,14 @@ begin
raise ESessionRejected.Create('missing csrf'); raise ESessionRejected.Create('missing csrf');
end; end;
// Bind the CSRF check to the session actually PRESENTED (token_hash), not
// "the user's most recent session" (§6c-3). The old ORDER BY created_at
// could read a different session's token — and created_at's 1-second
// resolution made two same-second sessions a coin-flip → phantom 403s.
// user_id stays as a filter (defense in depth). Expiry isn't re-checked:
// Authenticate always runs before RequireCSRF and already did it.
LTokenHash := SHA256Hex(ExtractBearerToken(ARequest));
DB.Lock; DB.Lock;
try try
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
@@ -119,8 +128,8 @@ begin
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := LQ.SQL.Text :=
'SELECT csrf_token FROM sessions ' + 'SELECT csrf_token FROM sessions ' +
'WHERE user_id = :uid AND expires_at > datetime(''now'') ' + 'WHERE token_hash = :th AND user_id = :uid';
'ORDER BY created_at DESC LIMIT 1'; LQ.ParamByName('th').AsString := LTokenHash;
LQ.ParamByName('uid').AsInteger := AUserId; LQ.ParamByName('uid').AsInteger := AUserId;
LQ.Open; LQ.Open;
if LQ.IsEmpty then if LQ.IsEmpty then
@@ -152,7 +161,11 @@ begin
ACSRFToken := RandomHex(32); ACSRFToken := RandomHex(32);
LTokenHash := SHA256Hex(AToken); LTokenHash := SHA256Hex(AToken);
// YYYY-MM-DD HH:NN:SS, +24h, server local time (api.php uses date() = local) // YYYY-MM-DD HH:NN:SS, +24h, server local time (api.php uses date() = local)
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24)); // UTC like everywhere else — RequireCSRF + CleanupExpired compare against
// SQLite datetime('now') which IS UTC. Writing local here made the CSRF
// window fall short on negative-offset zones (§6c-2): "Invalid CSRF token"
// on writes while GETs still authed.
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(NowUTC, 24));
DB.Lock; DB.Lock;
try try
+532 -33
View File
@@ -18,6 +18,7 @@ interface
uses uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
System.Diagnostics,
System.Net.HttpClient, System.Net.URLClient, System.Net.HttpClient, System.Net.URLClient,
Winapi.Windows, Winapi.ShellAPI, Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
@@ -33,7 +34,7 @@ uses
{$IFDEF USE_EDGE_BROWSER} {$IFDEF USE_EDGE_BROWSER}
FMX.TMSFNCEdgeWebBrowser, FMX.TMSFNCEdgeWebBrowser,
{$ENDIF} {$ENDIF}
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.AutoStart, PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.PinUnlock, PM.UserPrefs, PM.Licence, PM.AutoStart,
PM.Favicon, PM.Favicon,
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
const const
@@ -72,6 +73,24 @@ type
procedure btnToggleLogClick(Sender: TObject); procedure btnToggleLogClick(Sender: TObject);
procedure btnReloadClick(Sender: TObject); procedure btnReloadClick(Sender: TObject);
private private
// Windows minimising the window from the outside (Win+D / Show desktop /
// taskbar) makes FMX forget it was maximised: the window comes back
// "normal". None of our restore paths run in that case — the taskbar
// restore is pure Windows + FMX — so the state is re-applied on activate,
// which happens however the user comes back. FWantMaximized mirrors the
// user's LAST deliberate choice, so we never fight a normal-size window.
FWantMaximized: Boolean;
// Handle of the WinEvent hook that reports minimise start/end for our own
// window. FMX gives no usable signal (no resize while iconic, and the
// restore's resize is indistinguishable from a user gesture by placement
// flags or foreground ownership — both were measured and rejected).
FWinEventHook: THandle;
// True from "minimise starts" until just after the restore settles, so the
// resizes FMX fires while restoring are not recorded as user intent.
FIgnoreResizes: Boolean;
// Set from activate until the queued correction runs, so the restore's
// own resize is not mistaken for the user resizing the window.
FRestoring: Boolean;
FServer: TPMHTTPServer; FServer: TPMHTTPServer;
FBridge: TPMBridge; FBridge: TPMBridge;
// Accumulates base64 chunks for large file saves (attachments too big // Accumulates base64 chunks for large file saves (attachments too big
@@ -91,6 +110,16 @@ type
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
// FormCloseQuery's minimize-to-tray intercept. // FormCloseQuery's minimize-to-tray intercept.
FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute FAutofillTargetHWND: HWND; // saved at hotkey time, consumed on /execute
// CHRONOMETRE DU RACCOURCI, arme au moment ou l'on appelle la page.
//
// Un autofill lent apres une periode d'inactivite peut l'etre pour deux
// raisons sans rapport : le moteur WebView2 qui se reveille et met des
// secondes a repondre, ou la sequence de frappe elle-meme (prise du premier
// plan, attente des modificateurs). Les deux se ressemblent parfaitement de
// l'exterieur - l'utilisateur voit "ca a mis trois secondes" - et se
// corrigent a des endroits opposes. On mesure donc les deux segments
// separement plutot que de deviner lequel accuser.
FAutofillClock: TStopwatch;
// Pending payload for the 60ms delay timer (focus settle before SendInput). // Pending payload for the 60ms delay timer (focus settle before SendInput).
// Cleared inside AutofillTimerTick. // Cleared inside AutofillTimerTick.
FAutofillPendingHWND: HWND; FAutofillPendingHWND: HWND;
@@ -102,6 +131,11 @@ type
// SendInput because Win10/11 anti-focus-stealing rules then refuse to // SendInput because Win10/11 anti-focus-stealing rules then refuse to
// hand focus to the target window. // hand focus to the target window.
FAutofillPendingHide: Boolean; FAutofillPendingHide: Boolean;
// Same idea for the Ctrl+Shift+L/P/T hotkeys: captured when the hotkey
// fires, because the multi-match picker pulls the window out of the tray
// before the user picks, so by execute time the state is already lost.
// JS never sends hide_after on this path — only quick-search does.
FAutofillHotkeyWasHidden: Boolean;
// True when the pending autofill should type ONLY the username (quick // True when the pending autofill should type ONLY the username (quick
// search "autofill username" — right-click / Shift+Enter in fill mode). // search "autofill username" — right-click / Shift+Enter in fill mode).
FAutofillPendingUserOnly: Boolean; FAutofillPendingUserOnly: Boolean;
@@ -116,6 +150,7 @@ type
// needing two .fmx variants. Aligned to Client to fill the remaining // needing two .fmx variants. Aligned to Client to fill the remaining
// space between PanelTop (top) and PanelLog/Splitter (bottom). // space between PanelTop (top) and PanelLog/Splitter (bottom).
WebBrowser: TWebBrowserClass; WebBrowser: TWebBrowserClass;
procedure FormResizeTrackState(Sender: TObject);
procedure AutofillTimerTick(Sender: TObject); procedure AutofillTimerTick(Sender: TObject);
// Shared by cmd://file/save (single-shot) and file/save-commit // Shared by cmd://file/save (single-shot) and file/save-commit
// (chunked): decode the base64, show the Save dialog, write, and fire // (chunked): decode the base64, show the Save dialog, write, and fire
@@ -147,21 +182,107 @@ type
procedure BridgeQuickSearchHotkey(AKind: TAutofillKind; procedure BridgeQuickSearchHotkey(AKind: TAutofillKind;
ATargetHWND: HWND; const ATitle: string); ATargetHWND: HWND; const ATitle: string);
procedure WebBrowserInitialized(Sender: TObject); procedure WebBrowserInitialized(Sender: TObject);
public
// Called from the WinEvent hook. WINEVENT_OUTOFCONTEXT delivers through
// our own message queue, so these already run on the main thread.
// Every tray restore goes through here so the minimise guard cannot get
// stuck raised (see the implementation).
// One-shot: hides the window the first time FMX shows it on a --tray
// launch, then unhooks itself.
// True when a native modal dialog owns us — see the implementation.
function NativeModalActive: Boolean;
procedure RestoreWindowFromTray;
procedure WinStateMinimizeStart;
procedure WinStateMinimizeEnd;
procedure ApplyWantedWindowState;
end; end;
var var
MainForm: TMainForm; MainForm: TMainForm;
// Call from the .dpr right after Application.Initialize, BEFORE CreateForm,
// for a --tray launch. FMX shows its taskbar proxy before FormCreate ever
// runs — measured — so anything installed from inside the form is already too
// late. Starts a worker that hides the proxy the instant it exists and keeps
// watching until the tray startup completes; the main thread is blocked
// loading the .fmx and starting the server, so only another thread can react.
procedure StartTrayProxyHider;
implementation implementation
{$R *.fmx} {$R *.fmx}
// Result slot for EnumOwnDialogProc — the enumeration is main-thread only and
// consumed immediately, so a unit variable is enough.
var
GFoundDialogHwnd: HWND = 0;
// Forward — used by WebBrowserInitialized (which sits above the actual // Forward — used by WebBrowserInitialized (which sits above the actual
// definition lower in the unit). // definition lower in the unit).
function MaskAccessToken(const AUrl: string): string; forward; function MaskAccessToken(const AUrl: string): string; forward;
// ---- WinEvent hook plumbing (see the maximised-state block further down) --
const
EVENT_SYSTEM_MINIMIZESTART = $0016;
EVENT_SYSTEM_MINIMIZEEND = $0017;
WINEVENT_OUTOFCONTEXT = $0000;
OBJID_WINDOW = 0;
function SetWinEventHook(eventMin, eventMax: DWORD; hmodWinEventProc: HMODULE;
lpfnWinEventProc: Pointer; idProcess, idThread, dwFlags: DWORD): THandle;
stdcall; external 'user32.dll' name 'SetWinEventHook';
function UnhookWinEvent(hWinEventHook: THandle): BOOL;
stdcall; external 'user32.dll' name 'UnhookWinEvent';
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall; forward;
procedure StartTrayProxyHider;
begin
// ApplicationHWND (FMX.Platform.Win) IS the taskbar proxy — no hunting for a
// TFMAppClass window, and it is valid this early, before the form exists.
ShowWindow(ApplicationHWND, SW_HIDE);
// Hiding alone does not reliably drop the button: the taskbar had already
// drawn it. Clearing WS_EX_APPWINDOW and setting WS_EX_TOOLWINDOW does —
// a tool window is never listed. Undone by TrayProxyShowInTaskbar before any
// restore, otherwise the app would come back with no taskbar button at all.
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_APPWINDOW))
or WS_EX_TOOLWINDOW);
end;
// Give the proxy its normal styles back. Takes effect on the next show, which
// is exactly what RestoreFromTray does right after.
procedure TrayProxyShowInTaskbar;
begin
SetWindowLong(ApplicationHWND, GWL_EXSTYLE,
(GetWindowLong(ApplicationHWND, GWL_EXSTYLE) and (not WS_EX_TOOLWINDOW))
or WS_EX_APPWINDOW);
end;
procedure TMainForm.FormCreate(Sender: TObject); procedure TMainForm.FormCreate(Sender: TObject);
begin begin
// --tray ("Start with Windows"): the taskbar proxy was already dealt with in
// the .dpr (StartTrayProxyHider) — it is shown before FormCreate is even
// reached. This just keeps the form itself off screen.
if FindCmdLineSwitch('tray', True) then
Visible := False;
// The .fmx ships WindowState = wsMaximized, so that is the starting intent.
// Wired here (not in the .fmx) to keep the designer file untouched.
FWantMaximized := (WindowState = TWindowState.wsMaximized);
FIgnoreResizes := False;
// Scoped to our own process; WINEVENT_OUTOFCONTEXT means no DLL injection
// and the callback arrives on this thread. Failure is non-fatal — we just
// lose the maximised-state preservation.
FWinEventHook := SetWinEventHook(
EVENT_SYSTEM_MINIMIZESTART, EVENT_SYSTEM_MINIMIZEEND, 0,
@WinStateEventProc, GetCurrentProcessId, 0, WINEVENT_OUTOFCONTEXT);
LogLine('[winstate] minimise hook installed=' +
BoolToStr(FWinEventHook <> 0, True));
FRestoring := False;
OnResize := FormResizeTrackState;
// Create the WebBrowser control programmatically so the {$IFDEF // Create the WebBrowser control programmatically so the {$IFDEF
// USE_EDGE_BROWSER} directive can swap the concrete class without a // USE_EDGE_BROWSER} directive can swap the concrete class without a
// second .fmx variant. Align Client to fill the area below PanelTop // second .fmx variant. Align Client to fill the area below PanelTop
@@ -202,6 +323,9 @@ begin
FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey; FBridge.OnQuickSearchHotkey := BridgeQuickSearchHotkey;
FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup FBridge.RegisterAutofillHotkey; // Ctrl+Shift+L active from startup
FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later FBridge.ApplyTitleBarTheme(True); // dark by default, JS may toggle later
// Apres le theme et apres OnTrayRestore : la barrette se peint elle-meme, et
// son bouton Open passe par cet evenement.
FBridge.RestoreLazyBar;
FAutofillTargetHWND := 0; FAutofillTargetHWND := 0;
// Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores // Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores
// Navigate() calls until Edge Chromium finishes its async init (~1-2s). // Navigate() calls until Edge Chromium finishes its async init (~1-2s).
@@ -236,13 +360,27 @@ begin
FreeAndNil(configList); FreeAndNil(configList);
end; end;
end; end;
// Starting the server (DB open + Indy bind + WebView2 navigation) is what
// makes FormCreate run for ~1 s. On a --tray launch it therefore runs AFTER
// the tray hide, in the deferred block below, so FormCreate returns in
// milliseconds and the app is tray-resident right away.
//
// NOTE: this was originally needed to shrink the window during which the FMX
// taskbar proxy was visible. That is no longer the reason — the proxy is now
// dealt with properly in the .dpr (StartTrayProxyHider, WS_EX_TOOLWINDOW).
// The ordering is kept because it works and reaching the tray sooner is
// desirable, NOT because anything depends on it. A normal launch keeps the
// original order. FServer is constructed earlier (see above), so FServer.Active
// reads False rather than faulting while the start is pending.
if not FindCmdLineSwitch('tray', True) then
btnStartClick(Nil); btnStartClick(Nil);
btnToggleLogClick(nil); btnToggleLogClick(nil);
// CLI flag --tray (set by the "Start with Windows" registry entry): // CLI flag --tray (set by the "Start with Windows" registry entry):
// launch directly into the tray instead of popping a window during // launch directly into the tray instead of popping a window during
// the Windows login. Defer to ForceQueue so the form has finished its // the Windows login. The window was already made invisible at the top of
// initial Show before we hide it — minimises the visible flash. // FormCreate; this adds the tray icon and drops the taskbar entry. Still
// deferred: it is also the safety net if FMX shows the main form anyway.
if FindCmdLineSwitch('tray', True) then if FindCmdLineSwitch('tray', True) then
begin begin
// Suppress the first-time tray balloon for autostart launches — // Suppress the first-time tray balloon for autostart launches —
@@ -252,14 +390,27 @@ begin
TThread.ForceQueue(nil, TThread.ForceQueue(nil,
procedure procedure
begin begin
FBridge.MinimizeToTray; // Hand the foreground back: a -tray launch DOES take it for a moment —
LogLine('Launched with --tray, minimised at startup (balloon suppressed)'); // FMX puts its taskbar proxy up before we hide it — so the application
// that launched us (a console, Explorer) would otherwise be left
// inactive. GStartupForeground caught it before any window of ours
// existed. Nothing to give back on a Windows-startup launch: the
// foreground is then a shell window, which the capture filters out.
FBridge.MinimizeToTray(True, True);
// Hidden now — safe to pay the startup cost.
btnStartClick(Nil);
LogLine('Launched with --tray, started hidden (balloon suppressed)');
end); end);
end; end;
end; end;
procedure TMainForm.FormDestroy(Sender: TObject); procedure TMainForm.FormDestroy(Sender: TObject);
begin begin
if FWinEventHook <> 0 then
begin
UnhookWinEvent(FWinEventHook);
FWinEventHook := 0;
end;
if Assigned(FFileSaveChunks) then if Assigned(FFileSaveChunks) then
begin begin
for var LSB in FFileSaveChunks.Values do LSB.Free; for var LSB in FFileSaveChunks.Values do LSB.Free;
@@ -340,22 +491,94 @@ begin
// cmd://autofill/execute (fired after the user picks an entry) sends // cmd://autofill/execute (fired after the user picks an entry) sends
// the password into it. Then pop the modal in fill mode. // the password into it. Then pop the modal in fill mode.
if not FServer.Active then Exit; if not FServer.Active then Exit;
if NativeModalActive then Exit;
FAutofillTargetHWND := ATargetHWND; FAutofillTargetHWND := ATargetHWND;
// Tray-hidden ONLY (Visible=false). A window merely minimised to the // Tray-hidden ONLY (Visible=false). A window merely minimised to the
// taskbar must NOT count: hide-after would send it to the TRAY on close — // taskbar must NOT count: hide-after would send it to the TRAY on close —
// it vanishes from the taskbar it came from. Restored-from-iconic just // it vanishes from the taskbar it came from. Restored-from-iconic just
// stays open after the modal closes. // stays open after the modal closes.
LWasHidden := not Self.Visible; LWasHidden := not Self.Visible;
FBridge.RestoreFromTray; RestoreWindowFromTray;
// The window is back but the WebView2 child does NOT hold the keyboard
// focus, so nothing typed reaches the page — Esc did nothing until the user
// clicked the search box. DOM .focus() alone cannot fix that: the OS-level
// focus has to be on the browser control first.
WebBrowser.SetFocus;
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower; LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
// Second arg forFill=true tells JS to SendInput on pick instead of copy. // Second arg forFill tells JS to SendInput on pick instead of copy. The bridge
// reports HWND 0 when the foreground was our own hidden window: there is
// nothing to type into, so offer copy instead of a fill that goes nowhere.
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' + 'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
'Bridge.openQuickSearch(' + LWasHiddenJs + ',true)'); 'Bridge.openQuickSearch(' + LWasHiddenJs + ',' +
BoolToStr(ATargetHWND <> 0, True).ToLower + ')');
LogLine(Format('Quick-search hotkey — target=%s, title="%s"', LogLine(Format('Quick-search hotkey — target=%s, title="%s"',
[IntToHex(ATargetHWND, 8), ATitle])); [IntToHex(ATargetHWND, 8), ATitle]));
end; end;
// A native modal dialog (Save As, folder picker) DISABLES its owner window for
// as long as it is up. Restoring / focusing that owner then strands the dialog
// behind everything: the app looks frozen, when it is merely disabled by a modal
// the user can no longer find. Every hotkey entry point checks this first — and
// rather than just refusing, it surfaces the dialog, which is what the user
// actually needs.
// The common Save/Open dialog is NOT an owned popup of the form —
// GetLastActivePopup returns the form itself — but it does live on our thread
// and carries the standard dialog class. Enumerating the thread's windows finds
// it whoever its owner turns out to be.
function EnumOwnDialogProc(AWnd: HWND; AParam: LPARAM): BOOL; stdcall;
var
LCls: array[0..63] of Char;
begin
Result := True; // keep enumerating
if (AWnd = HWND(AParam)) or (not IsWindowVisible(AWnd)) then Exit;
if GetClassName(AWnd, LCls, Length(LCls)) = 0 then Exit;
if SameText(LCls, '#32770') then
begin
GFoundDialogHwnd := AWnd;
Result := False; // stop
end;
end;
function TMainForm.NativeModalActive: Boolean;
var
LHwnd, LPopup: HWND;
begin
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
Result := (LHwnd <> 0) and (not IsWindowEnabled(LHwnd));
if not Result then Exit;
GFoundDialogHwnd := 0;
EnumThreadWindows(GetCurrentThreadId, @EnumOwnDialogProc, LPARAM(LHwnd));
LPopup := GFoundDialogHwnd;
if LPopup = 0 then LPopup := GetLastActivePopup(LHwnd); // fallback
if (LPopup <> 0) and (LPopup <> LHwnd) and IsWindow(LPopup) then
begin
ShowWindow(LPopup, SW_SHOW);
// Windows refuses SetForegroundWindow to a process that isn't already in
// front, and the hotkey comes from ANOTHER application. The way through is
// to attach our input queue to the CURRENT FOREGROUND thread — not to the
// dialog's, which is our own thread and would make the attach a no-op
// (that is why the autofill helper, written for cross-process targets,
// left the dialog raised but unfocused).
var LFgThread := GetWindowThreadProcessId(GetForegroundWindow, nil);
var LOurThread := GetCurrentThreadId;
var LAttached := (LFgThread <> 0) and (LFgThread <> LOurThread) and
AttachThreadInput(LOurThread, LFgThread, True);
try
BringWindowToTop(LPopup);
SetForegroundWindow(LPopup);
finally
if LAttached then AttachThreadInput(LOurThread, LFgThread, False);
end;
// A tray balloon, not a toast: our own window is disabled and sits behind
// the dialog, so nothing rendered in the page would be seen.
FBridge.ShowBalloon('Finish the open dialog',
'A Save or folder dialog is waiting for an answer. Shortcuts stay ' +
'disabled until you close it.', True);
LogLine('Hotkey ignored — a native dialog is open; brought it to front.');
end;
end;
procedure TMainForm.BridgeQuickSearchRequest; procedure TMainForm.BridgeQuickSearchRequest;
var var
LWasHidden: Boolean; LWasHidden: Boolean;
@@ -365,12 +588,14 @@ begin
// see the modal, then ask JS to open it. JS handles the locked-vault // see the modal, then ask JS to open it. JS handles the locked-vault
// case (shows the auth screen with master-pw focused instead). // case (shows the auth screen with master-pw focused instead).
if not FServer.Active then Exit; if not FServer.Active then Exit;
if NativeModalActive then Exit;
// Remember whether the window was TRAY-hidden BEFORE we restore — after // Remember whether the window was TRAY-hidden BEFORE we restore — after
// the user picks an entry the JS layer asks us to minimize back so they // the user picks an entry the JS layer asks us to minimize back so they
// can paste into the target app without an extra alt-tab. Iconic-only // can paste into the target app without an extra alt-tab. Iconic-only
// (taskbar) does NOT count — see BridgeQuickSearchHotkey. // (taskbar) does NOT count — see BridgeQuickSearchHotkey.
LWasHidden := not Self.Visible; LWasHidden := not Self.Visible;
FBridge.RestoreFromTray; RestoreWindowFromTray;
WebBrowser.SetFocus; // see BridgeQuickSearchHotkey — keystrokes need it
LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower; LWasHiddenJs := BoolToStr(LWasHidden, True).ToLower;
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' + 'if(window.Bridge&&typeof Bridge.openQuickSearch==="function")' +
@@ -383,7 +608,8 @@ var
EscapedTitle: string; EscapedTitle: string;
begin begin
if not FServer.Active then Exit; if not FServer.Active then Exit;
FBridge.RestoreFromTray; if NativeModalActive then Exit;
RestoreWindowFromTray;
EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]); EscapedTitle := StringReplace(AWindowTitle, '\', '\\', [rfReplaceAll]);
EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]); EscapedTitle := StringReplace(EscapedTitle, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
@@ -863,6 +1089,10 @@ begin
// we register both with the supplied combos (replacing any prior). // we register both with the supplied combos (replacing any prior).
else if ACmd = 'autofill/hotkeys' then else if ACmd = 'autofill/hotkeys' then
begin begin
// Slot ids of the combos Windows refused (already held by another app).
// Ids, not labels: JS owns the wording AND uses them to flag the exact
// row in Settings. A combo the user cleared (vk=0) is never listed.
var LFailed := '';
// Quick-search combo is set unconditionally (independent of the // Quick-search combo is set unconditionally (independent of the
// autofill enabled flag) so the picker stays armed even when the // autofill enabled flag) so the picker stays armed even when the
// autofill hotkeys are turned off. // autofill hotkeys are turned off.
@@ -871,9 +1101,21 @@ begin
var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6)); var LQsMods := Word(StrToIntDef(GetParam('qs_mods'), 6));
var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q'))); var LQsVk := Word(StrToIntDef(GetParam('qs_vk'), Ord('Q')));
var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk); var LQsOk := FBridge.SetQuickSearchHotkey(LQsMods, LQsVk);
if (not LQsOk) and (LQsVk <> 0) then LFailed := LFailed + 'quickSearch,';
LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)', LogLine(Format('Quick-search hotkey set — mods:%d vk:%d (ok=%s)',
[LQsMods, LQsVk, BoolToStr(LQsOk, True)])); [LQsMods, LQsVk, BoolToStr(LQsOk, True)]));
end; end;
// New-entry combo — also independent of the autofill enabled flag
// (Ctrl+Shift+A works even with autofill turned off).
if GetParam('ne_vk') <> '' then
begin
var LNeMods := Word(StrToIntDef(GetParam('ne_mods'), 6));
var LNeVk := Word(StrToIntDef(GetParam('ne_vk'), Ord('A')));
var LNeOk := FBridge.SetNewEntryHotkey(LNeMods, LNeVk);
if (not LNeOk) and (LNeVk <> 0) then LFailed := LFailed + 'newEntry,';
LogLine(Format('New-entry hotkey set — mods:%d vk:%d (ok=%s)',
[LNeMods, LNeVk, BoolToStr(LNeOk, True)]));
end;
if GetParam('enabled') <> '1' then if GetParam('enabled') <> '1' then
begin begin
FBridge.UnregisterAutofillHotkey; FBridge.UnregisterAutofillHotkey;
@@ -885,16 +1127,27 @@ begin
var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L'))); var LFullVk := Word(StrToIntDef(GetParam('full_vk'), Ord('L')));
var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6)); var LPwdMods := Word(StrToIntDef(GetParam('pwd_mods'), 6));
var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P'))); var LPwdVk := Word(StrToIntDef(GetParam('pwd_vk'), Ord('P')));
// 2FA combo — vk 0 when the user cleared it, which skips registration.
var LTotpMods := Word(StrToIntDef(GetParam('totp_mods'), 6));
var LTotpVk := Word(StrToIntDef(GetParam('totp_vk'), Ord('T')));
var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk, var LAllOk := FBridge.SetAutofillHotkeys(LFullMods, LFullVk,
LPwdMods, LPwdVk); LPwdMods, LPwdVk,
LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d (all_ok=%s)', LTotpMods, LTotpVk);
[LFullMods, LFullVk, LPwdMods, LPwdVk, BoolToStr(LAllOk, True)])); LogLine(Format('Autofill hotkeys set — full=mods:%d vk:%d pwd=mods:%d vk:%d totp=mods:%d vk:%d (all_ok=%s)',
// Notify JS of the result so the UI can flag a failed-to-register combo [LFullMods, LFullVk, LPwdMods, LPwdVk, LTotpMods, LTotpVk, BoolToStr(LAllOk, True)]));
// (typically a clash with another app's global hotkey). if (not FBridge.AutofillFullActive) and (LFullVk <> 0) then
LFailed := LFailed + 'full,';
if (not FBridge.AutofillPwdActive) and (LPwdVk <> 0) then
LFailed := LFailed + 'password,';
if (not FBridge.AutofillTotpActive) and (LTotpVk <> 0) then
LFailed := LFailed + 'totp,';
end;
// One notification for the whole batch, naming the combos that failed —
// empty string = everything registered.
LFailed := LFailed.TrimRight([',', ' ']);
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' + 'if(window.Bridge&&Bridge.onAutofillHotkeysResult)' +
'Bridge.onAutofillHotkeysResult(' + BoolToStr(LAllOk, True).ToLower + ')'); 'Bridge.onAutofillHotkeysResult("' + LFailed.Replace('"', '') + '")');
end;
end end
// execute: JS has matched an entry, decrypted the password, and is // execute: JS has matched an entry, decrypted the password, and is
@@ -904,12 +1157,23 @@ begin
FAutofillPendingUser := GetParam('username'); FAutofillPendingUser := GetParam('username');
FAutofillPendingPass := GetParam('password'); FAutofillPendingPass := GetParam('password');
FAutofillPendingHWND := FAutofillTargetHWND; FAutofillPendingHWND := FAutofillTargetHWND;
FAutofillPendingHide := GetParam('hide_after') = '1'; FAutofillPendingHide := (GetParam('hide_after') = '1') or FAutofillHotkeyWasHidden;
FAutofillHotkeyWasHidden := False;
FAutofillPendingUserOnly := GetParam('field') = 'user'; FAutofillPendingUserOnly := GetParam('field') = 'user';
FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON FAutofillPendingClear := GetParam('clear') <> '0'; // absent = ON
FAutofillPendingNotify := GetParam('notify') <> '0'; // absent = ON FAutofillPendingNotify := GetParam('notify') <> '0'; // absent = ON
FAutofillTargetHWND := 0; FAutofillTargetHWND := 0;
// PREMIER SEGMENT : combien la PAGE a mis a repondre.
//
// Tout ce qui precede est du JavaScript - reveil du moteur, appariement du
// titre, dechiffrement du mot de passe. Si le chiffre est gros ici et petit
// plus bas, c'est le renderer qu'il faut regarder, pas la sequence de
// frappe. Le contraire designe SendInput et la prise du premier plan.
if FAutofillClock.IsRunning then
LogLine(Format('Autofill: page answered in %d ms',
[FAutofillClock.ElapsedMilliseconds]));
// Small timer so SetForegroundWindow has time to take effect before // Small timer so SetForegroundWindow has time to take effect before
// SendInput fires — avoids the first keystrokes going to our window. // SendInput fires — avoids the first keystrokes going to our window.
// TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a // TTimer.OnTimer is a TNotifyEvent (method, not anon proc) → we use a
@@ -926,9 +1190,23 @@ begin
// foreground process at that moment, so plain SetForegroundWindow works. // foreground process at that moment, so plain SetForegroundWindow works.
else if ACmd = 'autofill/cancel' then else if ACmd = 'autofill/cancel' then
begin begin
if FAutofillTargetHWND <> 0 then // refocus=0 — JS is REFUSING the request, not ending a user interaction:
// nothing must move on screen. During an export a native Save dialog owns
// the foreground, and pushing it behind leaves the app disabled by a modal
// the user can no longer find.
var LRefocus := GetParam('refocus') <> '0';
if LRefocus and (FAutofillTargetHWND <> 0) then
SetForegroundWindow(FAutofillTargetHWND); SetForegroundWindow(FAutofillTargetHWND);
FAutofillTargetHWND := 0; FAutofillTargetHWND := 0;
// The window only came out of the tray to show the picker, so dismissing it
// puts it back — same as quick-search, which hides on Esc too. Done after
// handing focus to the target above.
if FAutofillHotkeyWasHidden and LRefocus then
begin
FAutofillHotkeyWasHidden := False;
// Foreground already handed to the target above.
FBridge.MinimizeToTray(True, False);
end;
LogLine('Autofill cancelled (no match or dismissed)'); LogLine('Autofill cancelled (no match or dismissed)');
end end
@@ -939,7 +1217,7 @@ begin
// restores it later via ForceForegroundWindow. // restores it later via ForceForegroundWindow.
else if ACmd = 'app/focus' then else if ACmd = 'app/focus' then
begin begin
FBridge.RestoreFromTray; RestoreWindowFromTray;
LogLine('App brought to front (autofill picker)'); LogLine('App brought to front (autofill picker)');
end end
@@ -1026,6 +1304,25 @@ begin
'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")'); 'Bridge.onPrefResult("' + LEscapedKey + '","' + LEscapedVal + '")');
end end
// Existence probe. Some prefs ARE secrets (the auto-backup password, the sync
// passwords) and the UI mostly only needs to know whether one is set. Asking
// through prefs/get would interpolate the secret into an ExecuteJavaScript
// string — measured in a renderer dump, where it also showed up in the
// DevTools protocol buffers, and it stayed there after the vault auto-locked.
// The answer rides the same callback under "<key>#has".
else if ACmd = 'prefs/has' then
begin
var LHasKey := GetParam('key');
if LHasKey = '' then Exit;
var LEsc := StringReplace(LHasKey, '\', '\\', [rfReplaceAll]);
LEsc := StringReplace(LEsc, '"', '\"', [rfReplaceAll]);
var LSet := '';
if PM.UserPrefs.GetPref(LHasKey) <> '' then LSet := '1';
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onPrefResult)' +
'Bridge.onPrefResult("' + LEsc + '#has","' + LSet + '")');
end
else if ACmd = 'prefs/set' then else if ACmd = 'prefs/set' then
begin begin
var LKey := GetParam('key'); var LKey := GetParam('key');
@@ -1033,6 +1330,63 @@ begin
PM.UserPrefs.SetPref(LKey, GetParam('value')); PM.UserPrefs.SetPref(LKey, GetParam('value'));
end end
// ---- Licence hors ligne (PM.Licence) ---------------------------------
// cmd://licence/status -> Bridge.onLicenceResult(ok, porteur)
// cmd://licence/activate?key=<...> -> idem, apres verification + rangement
//
// La cle est du base64url plus un point : que des caracteres surs dans une
// URL, donc rien a encoder ni a decoder de special sur ce trajet.
// Desactiver pour eprouver l'activation. Garde par la presence de
// config.txt, comme Ctrl+Shift+D : un exe livre n'en a pas, la commande y
// est donc inerte. L'alternative — supprimer prefs.bin — emporterait aussi
// le mode de deverrouillage, le nom retenu et les reglages de sync.
else if ACmd = 'licence/clear' then
begin
if not FileExists('config.txt') then Exit;
PM.Licence.ClearLicence;
LogLine('Licence cleared (debug)');
end
else if (ACmd = 'licence/status') or (ACmd = 'licence/activate') then
begin
var LLicOk: Boolean;
var LLicInfo: TLicenceInfo;
if ACmd = 'licence/activate' then
begin
LLicOk := PM.Licence.ActivateLicence(GetParam('key'), LLicInfo);
// On journalise le VERDICT, jamais la cle : le journal se copie et
// s'envoie, et une licence valable est une valeur transmissible.
LogLine('Licence activation: ' + BoolToStr(LLicOk, True));
end
else
begin
LLicOk := PM.Licence.IsLicensed;
LLicInfo := PM.Licence.LicenceInfo;
end;
// Le nom, le courriel et la date viennent d'une charge utile QUE NOUS AVONS
// SIGNEE, mais ils sont interpoles dans du JavaScript : on les echappe
// comme n'importe quelle chaine etrangere. Signee ne veut pas dire
// inoffensive — c'est nous qui les avons tapes au moment d'emettre.
var LFields := TArray<string>.Create(
LLicInfo.Holder, LLicInfo.Email, LLicInfo.IssuedOn);
for var LI := 0 to High(LFields) do
begin
LFields[LI] := StringReplace(LFields[LI], '\', '\\', [rfReplaceAll]);
LFields[LI] := StringReplace(LFields[LI], '"', '\"', [rfReplaceAll]);
LFields[LI] := StringReplace(LFields[LI], #13, '\r', [rfReplaceAll]);
LFields[LI] := StringReplace(LFields[LI], #10, '\n', [rfReplaceAll]);
end;
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onLicenceResult)' +
'Bridge.onLicenceResult(' + BoolToStr(LLicOk, True).ToLower +
',"' + LFields[0] +
'","' + PM.Licence.ThisMachineId +
'","' + LFields[1] +
'","' + LFields[2] +
'",' + BoolToStr(FileExists('config.txt'), True).ToLower + ')');
end
// ---- Start with Windows (HKCU Run registry) -------------------------- // ---- Start with Windows (HKCU Run registry) --------------------------
else if ACmd = 'autostart/get' then else if ACmd = 'autostart/get' then
begin begin
@@ -1146,6 +1500,11 @@ begin
// send it as If-Match on the push so the server rejects (412) the // send it as If-Match on the push so the server rejects (412) the
// write when another device changed the file in between. // write when another device changed the file in between.
var LIfMatch := GetParam('ifmatch'); var LIfMatch := GetParam('ifmatch');
// GITEA, reconnu a l'URL. Meme transport, trois differences : le jeton
// remplace l'authentification Basic, le corps d'un PUT est du JSON que JS
// a deja mis en forme (le sha y voyage, pas dans un If-Match), et le test
// de connexion se fait par GET - l'API des contenus ne repond pas a HEAD.
var LGitea := LUrl.ToLower.Contains('/api/v1/repos/');
TThread.CreateAnonymousThread( TThread.CreateAnonymousThread(
procedure procedure
var var
@@ -1168,13 +1527,17 @@ begin
try try
LHttp.ConnectionTimeout := 10000; LHttp.ConnectionTimeout := 10000;
LHttp.ResponseTimeout := 30000; LHttp.ResponseTimeout := 30000;
if (LUser <> '') then // "token <PAT>" et non "Bearer" : c'est la forme que Gitea attend
// d'un jeton d'acces personnel, et l'autre donne un 401 muet.
if LGitea then
LHttp.CustomHeaders['Authorization'] := 'token ' + LPwd
else if (LUser <> '') then
begin begin
LHttp.CredentialsStorage.AddCredential( LHttp.CredentialsStorage.AddCredential(
System.Net.URLClient.TCredentialsStorage.TCredential.Create( System.Net.URLClient.TCredentialsStorage.TCredential.Create(
System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd)); System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd));
end; end;
if LMethod = 'webdav/get' then if (LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea) then
begin begin
LBodyStream := TBytesStream.Create; LBodyStream := TBytesStream.Create;
try try
@@ -1197,15 +1560,31 @@ begin
begin begin
LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
LReqStream := TBytesStream.Create(LBytes); LReqStream := TBytesStream.Create(LBytes);
// La reponse est LUE meme en echec : Gitea explique un 409 ou un
// 422 dans son corps, et sans cela le message d'erreur se
// reduisait au code, ce qui ne dit pas quoi corriger.
LBodyStream := TBytesStream.Create;
try try
if LIfMatch <> '' then if LGitea then
LResp := LHttp.Put(LUrl, LReqStream, nil, LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
[System.Net.URLClient.TNetHeader.Create('Content-Type', 'application/json')])
else if LIfMatch <> '' then
LResp := LHttp.Put(LUrl, LReqStream, LBodyStream,
[System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)]) [System.Net.URLClient.TNetHeader.Create('If-Match', LIfMatch)])
else else
LResp := LHttp.Put(LUrl, LReqStream); LResp := LHttp.Put(LUrl, LReqStream, LBodyStream);
LStatus := LResp.StatusCode; LStatus := LResp.StatusCode;
LEtag := LResp.HeaderValue['ETag']; LEtag := LResp.HeaderValue['ETag'];
if ((LStatus < 200) or (LStatus >= 300)) and (LBodyStream.Size > 0) then
begin
SetLength(LBytes, LBodyStream.Size);
Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size);
// Tronque : le corps d'une erreur tient en une phrase utile,
// et le reste n'a rien a faire dans un appel JavaScript.
LErr := Copy(TEncoding.UTF8.GetString(LBytes), 1, 300);
end;
finally finally
LBodyStream.Free;
LReqStream.Free; LReqStream.Free;
end; end;
end end
@@ -1233,7 +1612,8 @@ begin
// GET success path → ship body base64. Otherwise the field // GET success path → ship body base64. Otherwise the field
// carries either the empty string or the exception message // carries either the empty string or the exception message
// (for status=0 network errors). // (for status=0 network errors).
if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then if ((LMethod = 'webdav/get') or ((LMethod = 'webdav/test') and LGitea))
and (LStatus >= 200) and (LStatus < 300) then
LEscPayload := LBodyB64 LEscPayload := LBodyB64
else else
LEscPayload := LErr; LEscPayload := LErr;
@@ -1431,7 +1811,7 @@ end;
procedure TMainForm.BridgeTrayRestore; procedure TMainForm.BridgeTrayRestore;
begin begin
FBridge.RestoreFromTray; RestoreWindowFromTray;
// Notify the JS layer: the UI may want to reset the auto-lock timer, // Notify the JS layer: the UI may want to reset the auto-lock timer,
// refresh state, or show a "welcome back" toast. // refresh state, or show a "welcome back" toast.
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
@@ -1457,10 +1837,107 @@ begin
// Restore the form first so the tray icon goes away and FormDestroy // Restore the form first so the tray icon goes away and FormDestroy
// executes from a normal (non-hidden) state. RestoreFromTray also // executes from a normal (non-hidden) state. RestoreFromTray also
// deletes the tray icon. // deletes the tray icon.
FBridge.RestoreFromTray; RestoreWindowFromTray;
Application.Terminate; Application.Terminate;
end; end;
// ---- Window maximised-state preservation --------------------------------
// FMX forgets a window was maximised when Windows minimises it from the
// outside (Win+D / Show desktop / taskbar): it comes back "normal". None of
// our own restore paths run for a taskbar restore, and three cheaper signals
// were measured and rejected against real traces:
// * FMX WindowState — does not follow a native un-maximise
// * WPF_RESTORETOMAXIMIZED — set for ANY maximised window, not just a restore
// * foreground ownership — already ours during the restore's resize
// So observe the minimise itself, at the OS level, with a WinEvent hook.
procedure WinStateEventProc(hWinEventHook: THandle; AEvent: DWORD; AWnd: HWND;
idObject, idChild: LongInt; idEventThread, dwmsEventTime: DWORD); stdcall;
begin
if (idObject <> OBJID_WINDOW) or (AWnd = 0) or (MainForm = nil) then Exit;
// No HWND filter: an FMX app owns TWO top-level windows — the form and the
// taskbar proxy (see FindFMXAppWindow in PM.Bridge) — and Win+D minimises
// the proxy, which an equality test against the form handle rejected. The
// hook is already scoped to our own process, and WebView2 lives in separate
// processes, so anything arriving here is ours.
if AEvent = EVENT_SYSTEM_MINIMIZESTART then MainForm.WinStateMinimizeStart
else if AEvent = EVENT_SYSTEM_MINIMIZEEND then MainForm.WinStateMinimizeEnd;
end;
// Single entry point for every tray restore. ExecuteAutofill really minimises
// the window (SW_MINIMIZE) before typing, which raises the guard; if the window
// is then HIDDEN to the tray instead of being un-minimised, MINIMIZEEND may
// never arrive and the guard would stay up forever, silently freezing the
// tracked window state. RestoreFromTray replays its own saved placement, so
// there is nothing left for the guard to protect by then.
procedure TMainForm.RestoreWindowFromTray;
begin
FIgnoreResizes := False;
// A --tray launch turned the proxy into a tool window to keep it off the
// taskbar during startup; put it back before showing anything.
TrayProxyShowInTaskbar;
FBridge.RestoreFromTray;
end;
// The window is going down: from here until the restore settles, every resize
// FMX fires is part of that transition, never a user gesture.
procedure TMainForm.WinStateMinimizeStart;
begin
FIgnoreResizes := True;
end;
// Back up: re-apply what the user had.
procedure TMainForm.WinStateMinimizeEnd;
begin
// Inline, not queued: by the time this hook fires Windows has already put the
// window back at its normal size, and measurement showed nothing touches the
// state afterwards — the extra message-loop turn a ForceQueue costs was the
// whole flicker, one painted normal-sized frame before the correction landed.
ApplyWantedWindowState;
// Second pass anyway, in case FMX sizes the window after us on some path.
// Idempotent: it only acts when the state still disagrees.
TThread.ForceQueue(nil,
procedure
begin
ApplyWantedWindowState;
// Release the guard only after our own ShowWindow's resize has passed.
TThread.ForceQueue(nil, procedure begin FIgnoreResizes := False; end);
end);
end;
procedure TMainForm.ApplyWantedWindowState;
var
LHwnd: HWND;
begin
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized and (not IsZoomed(LHwnd)) then
begin
LogLine('[winstate] re-maximising after restore');
ShowWindow(LHwnd, SW_SHOWMAXIMIZED);
end
else if (not FWantMaximized) and IsZoomed(LHwnd) then
begin
LogLine('[winstate] restoring to normal after restore');
ShowWindow(LHwnd, SW_RESTORE);
end;
end;
// Track the state the user settles on. Ignored entirely while a minimise /
// restore is in flight — that is the whole point of the hook above.
procedure TMainForm.FormResizeTrackState(Sender: TObject);
var
LHwnd: HWND;
begin
if FIgnoreResizes then Exit;
LHwnd := WindowHandleToPlatform(Self.Handle).Wnd;
if (LHwnd = 0) or IsIconic(LHwnd) then Exit;
if FWantMaximized <> IsZoomed(LHwnd) then
begin
FWantMaximized := IsZoomed(LHwnd);
end;
end;
procedure TMainForm.AutofillTimerTick(Sender: TObject); procedure TMainForm.AutofillTimerTick(Sender: TObject);
var var
TargetHwnd: HWND; TargetHwnd: HWND;
@@ -1492,9 +1969,18 @@ begin
var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass, var LFillOk := FBridge.ExecuteAutofill(TargetHwnd, PendingUser, PendingPass,
UserOnly, not HideAfter, ClearFirst); UserOnly, not HideAfter, ClearFirst);
ForegroundAfter := GetForegroundWindow; ForegroundAfter := GetForegroundWindow;
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s', // SECOND SEGMENT, et le total. La difference entre les deux chiffres est le
// cout de la frappe : minimisation, prise du premier plan, attente de la
// relache des modificateurs, puis SendInput.
LogLine(Format('Autofill executed — target=%s, foreground_after=%s, ok=%s, ' +
'total=%d ms',
[IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8), [IntToHex(TargetHwnd, 8), IntToHex(ForegroundAfter, 8),
BoolToStr(LFillOk, True)])); BoolToStr(LFillOk, True), FAutofillClock.ElapsedMilliseconds]));
FAutofillClock.Stop;
// Le detail des deux attentes internes : sans lui, un total de trois secondes
// ne dit pas LAQUELLE a enfle, et les deux se corrigent differemment.
if FBridge.LastFillWaits <> '' then
LogLine('Autofill waits — ' + FBridge.LastFillWaits);
// Tell JS whether the keystrokes were actually sent — the success toast // Tell JS whether the keystrokes were actually sent — the success toast
// must not lie when the target runs elevated (UIPI drops our input). // must not lie when the target runs elevated (UIPI drops our input).
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
@@ -1514,7 +2000,8 @@ begin
// confusing the Win10/11 foreground-stealing watchdog. // confusing the Win10/11 foreground-stealing watchdog.
if HideAfter then if HideAfter then
begin begin
FBridge.MinimizeToTray; // The filled window must keep the foreground we just handed it.
FBridge.MinimizeToTray(True, False);
LogLine('Hidden back to tray (post-autofill).'); LogLine('Hidden back to tray (post-autofill).');
end; end;
end; end;
@@ -1525,19 +2012,31 @@ var
LTitle, LKind: string; LTitle, LKind: string;
begin begin
if not FServer.Active then Exit; if not FServer.Active then Exit;
if NativeModalActive then Exit;
FAutofillTargetHWND := ATargetHWND; FAutofillTargetHWND := ATargetHWND;
// Tray-hidden only, like the quick-search path: a window merely minimised to
// the taskbar must not come back as a tray icon it never was.
FAutofillHotkeyWasHidden := not Self.Visible;
// Escape the title for safe injection into a JS string literal. // Escape the title for safe injection into a JS string literal.
LTitle := ATitle; LTitle := ATitle;
LTitle := LTitle.Replace('\', '\\'); LTitle := LTitle.Replace('\', '\\');
LTitle := LTitle.Replace('"', '\"'); LTitle := LTitle.Replace('"', '\"');
if AKind = akPasswordOnly then LKind := 'password' else LKind := 'full'; case AKind of
akPasswordOnly: LKind := 'password';
akTotp: LKind := 'totp';
else LKind := 'full';
end;
// Arme AVANT l'appel : ce qui suit se passe dans le moteur, et c'est
// justement ce temps-la qu'on cherche a voir.
FAutofillClock := TStopwatch.StartNew;
WebBrowser.ExecuteJavaScript( WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' + 'if(window.Bridge&&typeof Bridge.onAutofillRequest==="function")' +
'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")'); 'Bridge.onAutofillRequest("' + LTitle + '","' + LKind + '")');
LogLine('Autofill hotkey (' + LKind + ') — foreground: "' + ATitle + '"'); LogLine('Autofill ' + IfThen(FBridge.LastRequestFromBar, 'fill bar', 'hotkey') +
' (' + LKind + ') — foreground: "' + ATitle + '"');
end; end;
initialization initialization
+54 -1
View File
@@ -155,6 +155,59 @@ if ($jsFiles) {
} }
} }
# --- Brouillage des blocs embarques -------------------------------------------
#
# CE N'EST PAS DU SECRET, C'EST DE L'OPACITE. La cle vit dans l'exe, donc qui
# tient le binaire retrouve le texte. Ce que ca supprime, c'est l'onglet
# "Image" de Process Explorer et n'importe quel `strings` : le frontend n'est
# plus lisible en survolant le fichier. La memoire du processus, un debogueur
# ou la reponse HTTP le rendent toujours - WebView2 doit recevoir du
# JavaScript en clair pour l'executer.
#
# L'AUTRE MOITIE EST DANS PM.EmbeddedAssets.pas ET LES DEUX DOIVENT S'ACCORDER :
#
# bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
# sortie = XOR( magie 'PMA1' + contenu du fichier )
#
# La magie prefixe le CLAIR : elle permet a l'exe de reconnaitre une cle qui a
# derive et de refuser, au lieu de servir du binaire au navigateur (ecran blanc
# sans trace). Symptome d'un desaccord : /index.html en 404.
$ObfSecret = [byte[]]@(
0x9A,0xAC,0xC6,0x05,0xDE,0xD6,0xFA,0xE7,
0x4F,0x95,0x04,0xE4,0x2C,0x1D,0xDF,0x26,
0x9B,0xBA,0xC9,0xD2,0x0A,0x52,0xD5,0x90,
0xB1,0x5E,0xA8,0x09,0x12,0x9B,0x9E,0x6C
)
$ObfMagic = [Text.Encoding]::ASCII.GetBytes('PMA1')
function Obfuscate([byte[]] $plain, [string] $resName) {
$sha = [System.Security.Cryptography.SHA256]::Create()
$seed = $ObfSecret + [Text.Encoding]::UTF8.GetBytes($resName)
$out = New-Object byte[] $plain.Length
$i = 0; $idx = 0
while ($i -lt $plain.Length) {
$block = $sha.ComputeHash($seed + [BitConverter]::GetBytes([uint32]$idx))
for ($j = 0; $j -lt $block.Length -and ($i + $j) -lt $plain.Length; $j++) {
$out[$i + $j] = $plain[$i + $j] -bxor $block[$j]
}
$i += $block.Length; $idx++
}
return $out
}
# Les blocs brouilles vont dans un dossier a part : brcc32 lit ce que le .rc
# lui montre, et on ne veut surtout pas ecraser les sources en clair.
$ObfDir = Join-Path $ScriptDir 'obf'
if (Test-Path $ObfDir) { Remove-Item $ObfDir -Recurse -Force }
New-Item -ItemType Directory -Path $ObfDir | Out-Null
foreach ($f in $files) {
$plain = $ObfMagic + [System.IO.File]::ReadAllBytes($f.FullPath)
$dest = Join-Path $ObfDir ($f.ResName + '.bin')
[System.IO.File]::WriteAllBytes($dest, (Obfuscate $plain $f.ResName))
$f | Add-Member -NotePropertyName ObfPath -NotePropertyValue $dest -Force
}
Log "Brouille $($files.Count) bloc(s) dans $ObfDir"
# --- Generate assets.rc ------------------------------------------------------- # --- Generate assets.rc -------------------------------------------------------
$rc = New-Object System.Text.StringBuilder $rc = New-Object System.Text.StringBuilder
[void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.') [void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.')
@@ -162,7 +215,7 @@ $rc = New-Object System.Text.StringBuilder
[void]$rc.AppendLine('') [void]$rc.AppendLine('')
foreach ($f in $files) { foreach ($f in $files) {
# brcc32 accepts forward slashes; backslashes need to be doubled in C strings # brcc32 accepts forward slashes; backslashes need to be doubled in C strings
$rcPath = $f.FullPath -replace '\\','\\' $rcPath = $f.ObfPath -replace '\\','\\'
[void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"") [void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"")
} }
[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII) [System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII)
+87 -66
View File
@@ -85,6 +85,34 @@
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- AUTH SCREEN (login + register) --> <!-- AUTH SCREEN (login + register) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- ============================================================ -->
<!-- LICENCE GATE (avant tout, cf. ensureLicence) -->
<!-- ============================================================ -->
<section id="licenceScreen" class="auth-screen is-hidden">
<div class="auth-card">
<div class="auth-brand">
<svg class="auth-brand-icon"><use href="#i-lock"/></svg>
<h1>Activate Vault</h1>
<p>Paste the licence key you were sent.</p>
</div>
<div id="licenceHint" class="auth-hint"></div>
<form id="licenceForm" class="auth-form" autocomplete="off">
<label class="field">
<span>Licence key</span>
<input id="licenceKey" type="text" spellcheck="false" required>
</label>
<button type="submit" class="btn btn-primary btn-block">Activate</button>
</form>
<div class="licence-machine">
<span>Installation ID — send this to get your key</span>
<code id="licenceMachine">&mdash;</code>
</div>
</div>
</section>
<section id="authScreen" class="auth-screen"> <section id="authScreen" class="auth-screen">
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"> <div class="auth-brand">
@@ -241,6 +269,14 @@
<svg><use href="#i-list"/></svg> <svg><use href="#i-list"/></svg>
<span>Audit log</span> <span>Audit log</span>
</button> </button>
<!-- SYNCHRONISER EST UNE ACTION, PAS UN REGLAGE. Elle n'existait
qu'au fond du panneau des reglages, a cote de l'URL et du
jeton : on l'y configure une fois, on la declenche tous les
jours. Sa place est ici, avec les autres gestes. -->
<button class="nav-item" id="sidebarSyncBtn">
<svg><use href="#i-rotate-ccw"/></svg>
<span>Sync now</span>
</button>
<button class="nav-item" id="sidebarImportBtn"> <button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg> <svg><use href="#i-log-in"/></svg>
<span>Import vault</span> <span>Import vault</span>
@@ -286,12 +322,6 @@
<svg><use href="#i-table"/></svg> <svg><use href="#i-table"/></svg>
</button> </button>
</div> </div>
<div class="cols-wrap" id="colsWrap" style="display:none;position:relative">
<button class="icon-btn" id="colsBtn" title="Show / hide columns">
<svg><use href="#i-settings"/></svg>
</button>
<div class="cols-menu is-hidden" id="colsMenu"></div>
</div>
<div class="filters-wrap"> <div class="filters-wrap">
<button class="icon-btn" id="filtersBtn" title="Filters"> <button class="icon-btn" id="filtersBtn" title="Filters">
<svg><use href="#i-list"/></svg> <svg><use href="#i-list"/></svg>
@@ -681,12 +711,24 @@
</span> </span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button> <button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillPwdCombo">Ctrl+Shift+P</button>
</div> </div>
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
2FA code only (click the 2FA field first)
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingAutofillTotpCombo">Ctrl+Shift+T</button>
</div>
<div class="setting-row"> <div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)"> <span style="font-size:12px;color:var(--text-dim)">
Quick search picker (find entry, fill from anywhere) Quick search picker (find entry, fill from anywhere)
</span> </span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingQuickSearchCombo">Ctrl+Shift+Q</button> <button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingQuickSearchCombo">Ctrl+Shift+Q</button>
</div> </div>
<div class="setting-row">
<span style="font-size:12px;color:var(--text-dim)">
New entry from the foreground window title
</span>
<button class="btn btn-ghost btn-sm hotkey-capture-btn" id="settingNewEntryCombo">Ctrl+Shift+A</button>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4"> <p style="font-size:11px;color:var(--text-faint);margin:6px 0 0;line-height:1.4">
Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key. Click a button, then press your new combo. Needs Ctrl, Alt or Win + a letter / digit / F-key.
</p> </p>
@@ -750,10 +792,12 @@
</div> </div>
<div class="slideover-field" id="syncField" style="display:none"> <div class="slideover-field" id="syncField" style="display:none">
<div class="slideover-field-label">Sync (WebDAV)</div> <div class="slideover-field-label">Sync (WebDAV or Gitea)</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5"> <p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
Bidirectional sync with a WebDAV server (Nextcloud, Bidirectional sync with a WebDAV server (Nextcloud,
ownCloud, Apache mod_dav). Auto-merge by entry ownCloud, Apache mod_dav) <b>or a private Gitea
repository</b> — paste a contents-API URL and it switches
transport on its own. Auto-merge by entry
timestamp. <b>Master password is never sent to the timestamp. <b>Master password is never sent to the
remote</b> — the snapshot is encrypted with a separate remote</b> — the snapshot is encrypted with a separate
sync password you set once per device (same on each). sync password you set once per device (same on each).
@@ -764,21 +808,28 @@
</label> </label>
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px"> <div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:6px"> <div style="margin-bottom:6px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">WebDAV URL (full path to the snapshot file)</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">URL — full path to the snapshot file, or a Gitea contents path</div>
<input type="text" id="syncUrl" class="so-input" <input type="text" id="syncUrl" class="so-input"
placeholder="https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json" placeholder="https://git.example.com/api/v1/repos/OWNER/REPO/contents/vault-sync.json"
style="width:100%;font-size:12px"> style="width:100%;font-size:12px">
</div> </div>
<div style="display:flex;gap:8px;margin-bottom:6px"> <div style="display:flex;gap:8px;margin-bottom:6px">
<label style="flex:1"> <label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username <span style="opacity:.7">(WebDAV only)</span></div>
<input type="text" id="syncUser" class="so-input" style="width:100%"> <input type="text" id="syncUser" class="so-input" style="width:100%">
</label> </label>
<label style="flex:1"> <label style="flex:1">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / app token</div> <div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / token</div>
<input type="password" id="syncPwd" class="so-input" style="width:100%"> <input type="password" id="syncPwd" class="so-input" style="width:100%">
</label> </label>
</div> </div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
On Gitea the username is ignored and the token needs
<b>write:repository</b> — a read-only one can pull but
never push. Each sync is a commit, and Git history is
kept: an old sync password stays attackable in earlier
versions, so prune the history if you rotate it.
</p>
<div style="margin-bottom:8px"> <div style="margin-bottom:8px">
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button> <button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
</div> </div>
@@ -939,6 +990,30 @@
</button> </button>
</div> </div>
</div> </div>
<div class="slideover-field" id="settingLicenceSection">
<div class="slideover-field-label">Licence</div>
<div class="setting-row">
<span>Licensed to</span>
<strong id="settingLicenceHolder">&mdash;</strong>
</div>
<div class="setting-row">
<span>Email</span>
<span id="settingLicenceEmail" style="color:var(--text-dim)">&mdash;</span>
</div>
<div class="setting-row">
<span>Issued</span>
<span id="settingLicenceIssued" style="color:var(--text-dim)">&mdash;</span>
</div>
<div class="setting-row">
<span>Installation ID</span>
<code id="settingLicenceMachine" style="font-family:ui-monospace,Consolas,monospace;user-select:all">&mdash;</code>
</div>
<button class="btn btn-ghost btn-sm is-danger" id="settingLicenceClear" hidden
style="margin-top:8px">
Deactivate (debug)
</button>
</div>
</div> </div>
</div> </div>
</aside> </aside>
@@ -1013,60 +1088,6 @@
</div> </div>
<!-- ============================================================ -->
<!-- MODAL: New / Edit Entry -->
<!-- ============================================================ -->
<div id="entryModal" class="modal is-hidden" role="dialog" aria-modal="true">
<div class="modal-backdrop" data-close></div>
<div class="modal-panel">
<header class="modal-header">
<h3 id="entryModalTitle">New entry</h3>
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
</header>
<form id="entryForm" class="modal-body" autocomplete="off">
<input type="hidden" id="entryId">
<label class="field">
<span>Display name <small style="color:var(--text-faint);font-weight:400">— shown on cards, also used for autofill matching</small></span>
<input id="entryTitle" type="text" placeholder="GitHub (work account)">
</label>
<label class="field">
<span>Website / App <small style="color:var(--text-faint);font-weight:400">— hostname or brand, used for autofill matching</small></span>
<input id="entrySite" type="text" required placeholder="github.com">
</label>
<label class="field">
<span>Username / Email</span>
<input id="entryUsername" type="text" placeholder="me@example.com">
</label>
<label class="field">
<span>Password</span>
<div class="input-with-action">
<input id="entryPassword" type="password" required placeholder="••••••••">
<button type="button" class="icon-btn" id="entryPwToggle"><svg><use href="#i-eye"/></svg></button>
<button type="button" class="icon-btn" id="entryPwGen" title="Generate"><svg><use href="#i-dice"/></svg></button>
</div>
<div class="strength-bar" id="entryStrengthBar"></div>
</label>
<label class="field">
<span>Folder</span>
<select id="entryFolder"></select>
</label>
<label class="field">
<span>Tags <span class="hint">enter to add</span></span>
<div class="chip-input" id="entryTagsInput">
<input id="entryTagsField" type="text" placeholder="add a tag…" autocomplete="off">
</div>
<input type="hidden" id="entryTags">
</label>
</form>
<footer class="modal-footer">
<button class="btn btn-ghost" data-close>Cancel</button>
<button class="btn btn-primary" id="entrySaveBtn">
<svg><use href="#i-check"/></svg> Save
</button>
</footer>
</div>
</div>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- MODAL: Password Generator --> <!-- MODAL: Password Generator -->
<!-- ============================================================ --> <!-- ============================================================ -->
+107 -6
View File
@@ -12,6 +12,43 @@
// Win32 modifier flags for RegisterHotKey. // Win32 modifier flags for RegisterHotKey.
const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 }; const WIN32_MOD = { alt: 0x0001, ctrl: 0x0002, shift: 0x0004, win: 0x0008 };
// The five configurable global hotkeys: slot id → Settings button + label.
// Delphi reports registration failures by slot id; this is the single place
// mapping them to UI, so a new hotkey only has to be added here.
const HOTKEY_SLOTS = {
full: { btn: '#settingAutofillFullCombo', label: 'Autofill (full)' },
password: { btn: '#settingAutofillPwdCombo', label: 'Password only' },
totp: { btn: '#settingAutofillTotpCombo', label: '2FA code' },
quickSearch: { btn: '#settingQuickSearchCombo', label: 'Quick search' },
newEntry: { btn: '#settingNewEntryCombo', label: 'New entry' },
};
// Flag the Settings rows whose combo Windows refused. Re-applied whenever the
// panel is (re)rendered, so the warning outlives the toast.
function renderHotkeyConflicts() {
const bad = new Set(state.hotkeyConflicts || []);
for (const [slot, def] of Object.entries(HOTKEY_SLOTS)) {
const btn = document.querySelector(def.btn);
if (!btn) continue;
const clash = bad.has(slot);
btn.classList.toggle('is-conflict', clash);
if (clash) btn.title = 'This combo is already used by another app — ' +
'the hotkey does nothing. Pick another one.';
else btn.removeAttribute('title');
}
// Dot on the Settings button: the ⚠ inside the panel only helps someone who
// opens it, and the toast may well have been missed. This is the only cue
// left in the main UI that a hotkey is silently dead.
const gear = document.getElementById('settingsBtn');
if (gear) {
gear.classList.toggle('has-alert', bad.size > 0);
if (bad.size > 0)
gear.title = 'A hotkey combo is already used by another app — see Autofill settings';
else if (gear.title.startsWith('A hotkey combo'))
gear.title = 'Settings';
}
}
// Format a combo for human display: "Ctrl+Shift+L". // Format a combo for human display: "Ctrl+Shift+L".
function autofillComboLabel(c) { function autofillComboLabel(c) {
if (!c || !c.key) return '— not set —'; if (!c || !c.key) return '— not set —';
@@ -78,15 +115,25 @@ function autofillCaptureFromEvent(e) {
// Push current state to Delphi (toggle + both combos) and persist. // Push current state to Delphi (toggle + both combos) and persist.
// Called after any change so Delphi's RegisterHotKey reflects state. // Called after any change so Delphi's RegisterHotKey reflects state.
function autofillPushHotkeys() { // Set by the startup push so the Delphi callback can stay quiet about
// success — "Hotkeys updated" on every unlock is noise. A CLASH is still
// reported: it's actionable and the user has no other way to learn about it.
let hotkeyPushSilent = false;
function autofillPushHotkeys(silent) {
hotkeyPushSilent = !!silent;
localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull)); localStorage.setItem('autofillHotkeyFull', JSON.stringify(state.autofillHotkeyFull));
localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd)); localStorage.setItem('autofillHotkeyPwd', JSON.stringify(state.autofillHotkeyPwd));
localStorage.setItem('autofillHotkeyTotp', JSON.stringify(state.autofillHotkeyTotp));
localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey)); localStorage.setItem('quickSearchHotkey', JSON.stringify(state.quickSearchHotkey));
localStorage.setItem('newEntryHotkey', JSON.stringify(state.newEntryHotkey));
if (Bridge.active) { if (Bridge.active) {
Bridge.setAutofillHotkeys(state.autofillEnabled, { Bridge.setAutofillHotkeys(state.autofillEnabled, {
full: state.autofillHotkeyFull, full: state.autofillHotkeyFull,
password: state.autofillHotkeyPwd, password: state.autofillHotkeyPwd,
totp: state.autofillHotkeyTotp,
quickSearch: state.quickSearchHotkey, quickSearch: state.quickSearchHotkey,
newEntry: state.newEntryHotkey,
}); });
} }
} }
@@ -189,10 +236,27 @@ function autofillReportResult(ok) {
async function autofillHandleRequest(windowTitle, kind) { async function autofillHandleRequest(windowTitle, kind) {
if (!state.autofillEnabled) return; if (!state.autofillEnabled) return;
// A blocking operation owns the screen (export, sync, rotation). Covers all
// three hotkeys and both outcomes: the multi-match picker would open behind
// the overlay, unreachable, and a single-match fill would minimise our
// window and hand the foreground away mid-operation. cancelAutofill so
// Delphi doesn't keep the captured target HWND for a fill that never comes.
if (typeof isBusy === 'function' && isBusy()) {
// refocus=false: refusing must not move any window. During an export a
// native Save dialog holds the foreground, and shoving it behind leaves
// the app disabled by a modal the user can no longer find.
Bridge.cancelAutofill(false);
toast('Busy — try again in a moment', 'warning');
return;
}
if (!state.cryptoKey || state.locked || !state.token) { if (!state.cryptoKey || state.locked || !state.token) {
// Vault is locked — bring the app to the front so the user can // Vault is locked — bring the app to the front so the user can
// unlock immediately, rather than silently no-op'ing the hotkey. // unlock immediately, rather than silently no-op'ing the hotkey.
Bridge.cancelAutofill(); // refocus=false: the default hands the foreground back to the target,
// which focusApp would then have to take away again — two window moves
// to end where one gets us, and the round trip is visible.
Bridge.cancelAutofill(false);
Bridge.focusApp(); Bridge.focusApp();
setTimeout(() => { setTimeout(() => {
const pwd = document.getElementById('loginPassword'); const pwd = document.getElementById('loginPassword');
@@ -207,13 +271,22 @@ async function autofillHandleRequest(windowTitle, kind) {
} }
const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase(); const titleLower = autofillStripBrowserSuffix(windowTitle).toLowerCase();
const scored = state.entries // The 2FA hotkey can only serve entries that actually carry a TOTP secret
// — filtering here (not after scoring) also means the picker never lists a
// candidate that would fail once chosen.
const pool = (kind === 'totp')
? state.entries.filter(e => e.totp_secret && e.totp_iv)
: state.entries;
const scored = pool
.map(e => ({ entry: e, score: autofillScore(e, titleLower) })) .map(e => ({ entry: e, score: autofillScore(e, titleLower) }))
.filter(x => x.score > 0) .filter(x => x.score > 0)
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score);
if (scored.length === 0) { if (scored.length === 0) {
toast('Autofill: no match for "' + windowTitle.slice(0, 40) + '"', 'warning'); toast(kind === 'totp'
? 'No 2FA code stored for "' + windowTitle.slice(0, 40) + '"'
: 'Autofill: no match for "' + windowTitle.slice(0, 40) + '"',
'warning');
Bridge.cancelAutofill(); Bridge.cancelAutofill();
return; return;
} }
@@ -228,8 +301,34 @@ async function autofillHandleRequest(windowTitle, kind) {
openAutofillPicker(scored.map(x => x.entry), windowTitle, kind); openAutofillPicker(scored.map(x => x.entry), windowTitle, kind);
} }
// Decrypt and type an entry. kind = 'full' or 'password'. // Decrypt and type an entry. kind = 'full' | 'password' | 'totp'.
async function autofillFillEntry(entry, kind) { async function autofillFillEntry(entry, kind) {
// 2FA: type the current 6-digit code instead of the password. Sent in the
// password slot with an empty username, so Delphi's password-only path
// types it as-is — no new native code path.
if (kind === 'totp') {
const secret = await decryptTotpSecret(entry.totp_secret, entry.totp_iv);
if (!secret || secret === '[ERROR]') {
toast('2FA: cannot decrypt the secret', 'error');
Bridge.cancelAutofill();
return;
}
let code;
try { code = (await generateTOTP(secret)).code; }
catch (e) {
toast('2FA: invalid secret for ' + entryDisplayName(entry), 'error');
Bridge.cancelAutofill();
return;
}
autofillPendingToast = '2FA code filled: ' + entryDisplayName(entry);
Bridge.executeAutofill('', code);
fetch('' + '/audit', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ action: 'autofill_totp', site: entry.site }),
}).catch(() => {});
return;
}
const password = await decryptPwd(entry.encrypted_password, entry.iv); const password = await decryptPwd(entry.encrypted_password, entry.iv);
if (password === '[ERROR]') { if (password === '[ERROR]') {
toast('Autofill: decryption error', 'error'); toast('Autofill: decryption error', 'error');
@@ -283,7 +382,9 @@ function openAutofillPicker(entries, windowTitle, kind) {
} }
list.appendChild(btn); list.appendChild(btn);
}); });
const head = (kind === 'password' ? 'Pick entry (password only) — ' : 'Pick entry — ') const head = (kind === 'totp' ? 'Pick entry (2FA code) — '
: kind === 'password' ? 'Pick entry (password only) — '
: 'Pick entry — ')
+ entries.length + ' match "' + windowTitle.slice(0, 30) + '…"'; + entries.length + ' match "' + windowTitle.slice(0, 30) + '…"';
$('#autofillPickerTitle').textContent = head; $('#autofillPickerTitle').textContent = head;
$('#autofillPickerModal').classList.remove('is-hidden'); $('#autofillPickerModal').classList.remove('is-hidden');
+21 -48
View File
@@ -24,15 +24,21 @@ const ABK = {
}; };
const AUTO_BACKUP_PREFIX = 'vault-autobackup-'; const AUTO_BACKUP_PREFIX = 'vault-autobackup-';
async function loadAutoBackupConfig() { // withPwd defaults to FALSE on purpose: the UI only ever needs to know whether
// a backup password exists. Pulling the value across the bridge leaves it in
// the renderer for the life of the process — verified in a memory dump, where
// it was still there after the vault had auto-locked and the vault key was
// gone. Only the two paths that actually encrypt a backup ask for it.
async function loadAutoBackupConfig(withPwd) {
if (!Bridge.active) return null; if (!Bridge.active) return null;
const [enabled, dir, interval, keep, last, pwd] = await Promise.all([ const [enabled, dir, interval, keep, last, pwd, hasPwd] = await Promise.all([
Bridge.getPref(ABK.enabled), Bridge.getPref(ABK.enabled),
Bridge.getPref(ABK.dir), Bridge.getPref(ABK.dir),
Bridge.getPref(ABK.interval), Bridge.getPref(ABK.interval),
Bridge.getPref(ABK.keep), Bridge.getPref(ABK.keep),
Bridge.getPref(ABK.last), Bridge.getPref(ABK.last),
Bridge.getPref(ABK.pwd), withPwd ? Bridge.getPref(ABK.pwd) : Promise.resolve(''),
Bridge.hasPref(ABK.pwd),
]); ]);
return { return {
enabled: enabled === '1', enabled: enabled === '1',
@@ -40,7 +46,7 @@ async function loadAutoBackupConfig() {
interval: Math.max(1, parseInt(interval, 10) || 7), interval: Math.max(1, parseInt(interval, 10) || 7),
keep: Math.max(1, parseInt(keep, 10) || 10), keep: Math.max(1, parseInt(keep, 10) || 10),
last: last || '', last: last || '',
hasPwd: !!pwd, hasPwd: !!hasPwd,
pwd, pwd,
}; };
} }
@@ -139,7 +145,8 @@ async function changeBackupPwd() {
} }
async function runAutoBackupNow(silent) { async function runAutoBackupNow(silent) {
const cfg = await loadAutoBackupConfig(); // withPwd: this is the one place that encrypts a backup.
const cfg = await loadAutoBackupConfig(true);
if (!cfg) return silent || toast('Bridge not available', 'error'); if (!cfg) return silent || toast('Bridge not available', 'error');
if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning'); if (!cfg.dir) return silent || toast('Choose a backup folder first', 'warning');
if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning'); if (!cfg.hasPwd) return silent || toast('Backup password not set', 'warning');
@@ -168,53 +175,17 @@ async function runAutoBackupNow(silent) {
avatar_b64: state.avatarDataUri || '', avatar_b64: state.avatarDataUri || '',
entries: [], entries: [],
}; };
// Same container as the manual export, trash included — see
// buildBackupEntry (app.import.js), which both paths share.
await loadTrash();
const toBackup = state.entries.concat(state.trashed || []);
let _bkDone = 0; let _bkDone = 0;
const _bkTotal = state.entries.length; const _bkTotal = toBackup.length;
for (const e of state.entries) { for (const e of toBackup) {
_bkDone++; _bkDone++;
if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal)) if (!silent && _bkTotal > 10 && (_bkDone % 5 === 0 || _bkDone === _bkTotal))
updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal); updateBusy('Reading vault… ' + _bkDone + '/' + _bkTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv); payload.entries.push(await buildBackupEntry(e));
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try { plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv); }
catch (_) { plainCustom = []; }
}
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(
full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename,
mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
payload.entries.push({
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments: attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at, updated_at: e.updated_at,
});
} }
if (!silent) updateBusy('Encrypting backup…'); if (!silent) updateBusy('Encrypting backup…');
const container = await encryptExportPayload(payload, cfg.pwd); const container = await encryptExportPayload(payload, cfg.pwd);
@@ -262,6 +233,8 @@ async function applyAutoBackupRetention(dir, keep) {
async function runAutoBackupIfDue() { async function runAutoBackupIfDue() {
if (!Bridge.active) return; if (!Bridge.active) return;
// No password yet: the due-check reads only metadata. runAutoBackupNow
// fetches the secret itself, and only if a run is actually starting.
const cfg = await loadAutoBackupConfig(); const cfg = await loadAutoBackupConfig();
if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return; if (!cfg || !cfg.enabled || !cfg.dir || !cfg.hasPwd) return;
if (!state.cryptoKey) return; if (!state.cryptoKey) return;
+12 -18
View File
@@ -118,9 +118,14 @@ async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) {
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) { async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) {
const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams); const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams);
// extractable:true is REQUIRED — Quick Unlock, PIN, recovery-code and
// master-password change all exportKey('raw') the vault key to (re)wrap or
// re-derive it. The CryptoKey keeps its own copy of the bytes; scrub ours
// from the heap so the raw key doesn't linger as a stray Uint8Array.
const cryptoKey = await crypto.subtle.importKey( const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); 'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo); const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
keyBytes.fill(0);
return { cryptoKey, verifier }; return { cryptoKey, verifier };
} }
@@ -152,21 +157,10 @@ async function decryptPwd(encB64, ivB64) {
} }
} }
async function persistCryptoKey() { // NB: the vault key is deliberately NOT cached anywhere it outlives the live
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); // CryptoKey. An earlier persistCryptoKey() stored the raw key base64 in
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); // sessionStorage to survive a page reload — but the WebView2 shell has no
} // reload path (F5/Ctrl+R blocked, all navigations are cmd:// bridge calls),
// so that copy only ever served as a plaintext, grep-able duplicate of the
async function restoreCryptoKey() { // master key in renderer memory. A cold start re-derives via Quick Unlock /
const saved = sessionStorage.getItem('cryptoKey'); // PIN / master-password instead.
if (!saved) return false;
try {
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
state.cryptoKey = await crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return true;
} catch (e) {
return false;
}
}
+24 -4
View File
@@ -38,12 +38,15 @@ function faviconHost(siteRaw) {
// Save the icon for one entry via the dedicated endpoint (no full PUT, // Save the icon for one entry via the dedicated endpoint (no full PUT,
// no re-encryption). Fire-and-forget: failures are silent so a flaky // no re-encryption). Fire-and-forget: failures are silent so a flaky
// network doesn't break the user's flow. // network doesn't break the user's flow.
async function saveEntryIcon(entryId, dataUri) { // isCustom = true for a user upload (preserved from automatic fetch/clear),
// false for an auto-fetched favicon or a clear. The server stores it in
// icon_custom so bulk fetch/clear can skip deliberate uploads.
async function saveEntryIcon(entryId, dataUri, isCustom) {
try { try {
const r = await fetch(API + '/entries/' + entryId + '/icon', { const r = await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST', method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '' }), body: JSON.stringify({ icon_b64: dataUri || '', custom: !!isCustom }),
}); });
if (!r.ok) { if (!r.ok) {
// Surface the server's reason so silent persistence failures // Surface the server's reason so silent persistence failures
@@ -52,7 +55,15 @@ async function saveEntryIcon(entryId, dataUri) {
try { const b = await r.json(); if (b && b.error) msg = b.error; } try { const b = await r.json(); if (b && b.error) msg = b.error; }
catch (_) {} catch (_) {}
toast('Icon NOT saved: ' + msg, 'error'); toast('Icon NOT saved: ' + msg, 'error');
return;
} }
// Keep the in-memory row's provenance in sync so backfill / clear
// filters see the right value before the next reload.
const ent = state.entries.find(x => x.id === entryId);
if (ent) ent.icon_custom = (dataUri && isCustom) ? 1 : 0;
// Choke point for every icon write (favicon backfill, upload, remove,
// import overwrite) — refresh the editor if it's open on this entry.
editorAbsorbChange(entryId, { icon: dataUri || '' });
} catch (e) { } catch (e) {
toast('Icon save failed: ' + (e && e.message || e), 'error'); toast('Icon save failed: ' + (e && e.message || e), 'error');
} }
@@ -85,7 +96,9 @@ async function backfillFavicons(force) {
const all = state.entries; const all = state.entries;
const eligible = all.filter(e => faviconHost(e.site)); const eligible = all.filter(e => faviconHost(e.site));
const skipped = all.length - eligible.length; const skipped = all.length - eligible.length;
const targets = eligible.filter(e => force || !e.icon_b64); // Never overwrite a user upload — not even on "Re-fetch all". Only the
// user changes a custom icon, per entry. Missing (no icon) still fetches.
const targets = eligible.filter(e => !e.icon_custom && (force || !e.icon_b64));
if (targets.length === 0) { if (targets.length === 0) {
if (skipped > 0) { if (skipped > 0) {
toast('No icons to fetch — ' + skipped + toast('No icons to fetch — ' + skipped +
@@ -118,7 +131,14 @@ async function clearAllFavicons() {
toast('Failed to clear icons', 'error'); toast('Failed to clear icons', 'error');
return; return;
} }
state.entries.forEach(e => { e.icon_b64 = null; }); // Only the auto-fetched cache is cleared locally — user uploads (custom)
// are preserved, mirroring the server's WHERE icon_custom = 0.
state.entries.forEach(e => { if (!e.icon_custom) e.icon_b64 = null; });
// Refresh the open editor's icon preview if it lost its icon.
if (soState && soState.id != null) {
const ent = state.entries.find(x => x.id === soState.id);
if (ent && !ent.icon_custom) editorAbsorbChange(ent.id, { icon: '' });
}
render(); render();
toast('Cached icons cleared'); toast('Cached icons cleared');
} }
+40 -2
View File
@@ -93,7 +93,13 @@ function healthScoreBand(score) {
// dismiss. Auto-opening the generator modal hid the entry context and // dismiss. Auto-opening the generator modal hid the entry context and
// forced an extra Save click — worse UX than this lighter nudge. // forced an extra Save click — worse UX than this lighter nudge.
async function openEntryForFix(entryId) { async function openEntryForFix(entryId) {
await openSlideOver(entryId); // Bail if the editor didn't actually switch (user cancelled the discard
// prompt): the steps below would unmask + select the password of whatever
// entry is still open, and pulse Generate on it — the wrong entry.
// noFocus: openSlideOver otherwise defers a focus+select on the title
// (setTimeout 80ms) which lands AFTER the password focus below and steals
// it back. Fix targets the password — it owns the focus here.
if (!(await openSlideOver(entryId, { noFocus: true }))) return;
const pwd = document.getElementById('soPassword'); const pwd = document.getElementById('soPassword');
if (pwd) { if (pwd) {
pwd.type = 'text'; // unmask so the user sees what they're replacing pwd.type = 'text'; // unmask so the user sees what they're replacing
@@ -123,7 +129,39 @@ async function renderHealthDashboard(grid) {
const scoreEl = el('div', { class: 'health-score ' + band.cls }); const scoreEl = el('div', { class: 'health-score ' + band.cls });
scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score))); scoreEl.appendChild(el('div', { class: 'health-score-num' }, String(h.score)));
scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label)); scoreEl.appendChild(el('div', { class: 'health-score-lbl' }, band.label));
header.appendChild(scoreEl); // Per-category breakdown. Each category is capped, so once a cap is reached
// fixing one more password leaves the total untouched — which reads as "my
// work did nothing". Showing the raw deduction next to the capped one makes
// the progress visible even when the score sits still.
const parts = [
{ lbl: 'Weak', n: h.weak.length, per: 5, cap: 40 },
{ lbl: 'Reused', n: h.reused.length, per: 10, cap: 30 },
{ lbl: 'Old', n: h.old.length, per: 2, cap: 20 },
{ lbl: 'Pwned', n: h.pwned.length, per: 15, cap: 50 },
];
const breakdown = el('div', { class: 'health-score-breakdown' });
parts.forEach(p => {
const raw = p.n * p.per;
const applied = Math.min(p.cap, raw);
const row = el('div', {
class: 'health-bd-row' + (p.n === 0 ? ' is-clear' : ''),
title: raw > applied
? p.lbl + ': ' + p.n + ' × ' + p.per + ' = −' + raw +
', capped at −' + p.cap + '. Fixing one more won\'t move the ' +
'score until you drop below the cap.'
: p.lbl + ': ' + p.n + ' × ' + p.per + ' points',
});
row.appendChild(el('span', { class: 'health-bd-lbl' }, p.lbl));
row.appendChild(el('span', { class: 'health-bd-n' }, String(p.n)));
row.appendChild(el('span', { class: 'health-bd-pts' },
applied === 0 ? '0' : '−' + applied + (raw > applied ? ' (max)' : '')));
breakdown.appendChild(row);
});
// Under the dial, not inside it — the circle is a fixed 110px.
const scoreCol = el('div', { class: 'health-score-col' });
scoreCol.appendChild(scoreEl);
scoreCol.appendChild(breakdown);
header.appendChild(scoreCol);
const intro = el('div', { class: 'health-intro' }); const intro = el('div', { class: 'health-intro' });
intro.appendChild(el('h3', null, 'How healthy is your vault?')); intro.appendChild(el('h3', null, 'How healthy is your vault?'));
intro.appendChild(el('p', null, intro.appendChild(el('p', null,
+147 -88
View File
@@ -414,8 +414,17 @@ function parseEntriesFromJSON(text) {
custom_fields: cf, custom_fields: cf,
attachments: atts, attachments: atts,
icon_b64: String(e.icon_b64 || '').trim(), icon_b64: String(e.icon_b64 || '').trim(),
icon_custom: e.icon_custom ? 1 : 0,
// Flags are part of the vault's state: a restore should give the
// list back the way it was. Absent (foreign CSV) → 0.
favorite: e.favorite ? 1 : 0,
pinned: e.pinned ? 1 : 0,
created_at: String(e.created_at || '').trim(), created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').trim(), updated_at: String(e.updated_at || '').trim(),
// Trash state, so a restore rebuilds the trash rather than
// resurrecting everything into the live list. Absent → live.
deleted: e.deleted ? 1 : 0,
deleted_at: String(e.deleted_at || '').trim(),
}); });
} }
return { entries, skipped, columns: null, folders, return { entries, skipped, columns: null, folders,
@@ -439,6 +448,34 @@ function normalizeImportTimestamp(s) {
return d.toISOString().slice(0, 19).replace('T', ' '); return d.toISOString().slice(0, 19).replace('T', ' ');
} }
/// Rend à `enc` le chiffré d'origine quand le mot de passe n'a pas changé.
///
/// POURQUOI CE N'EST PAS COSMÉTIQUE. Un conteneur — instantané de sync ou
/// sauvegarde — transporte le mot de passe EN CLAIR ; `encryptImportEntry` le
/// re-chiffre avec un IV neuf, donc le chiffré diffère TOUJOURS, même quand
/// rien n'a bougé. Or le serveur décide d'archiver une version d'historique en
/// comparant les chiffrés (`encrypted_password <> :newenc`) : il ne voit que du
/// chiffré et croit à un changement. Chaque synchronisation, chaque restauration
/// déposait donc un doublon daté du jour, et l'historique montrait la même
/// valeur plusieurs fois à des jours d'écart.
///
/// Seul le client connaît les deux clairs. Il compare ici, et rend le chiffré
/// stocké quand ils sont égaux — ce que le commentaire du handler appelle déjà
/// « JS reuses originalEncrypted bit-for-bit ». Le garde du serveur redevient
/// exact sans qu'il ait à déchiffrer quoi que ce soit.
///
/// À appeler AVANT tout PUT construit par encryptImportEntry sur une entrée qui
/// existe déjà. Les re-publications partielles (tag, dossier, migration) sont
/// hors de cause : elles réutilisent déjà le chiffré stocké.
async function keepCiphertextIfSamePassword(enc, local, plainPassword) {
if (!local || !local.encrypted_password || !local.iv) return enc;
const before = await decryptPwd(local.encrypted_password, local.iv);
if (before === '[ERROR]' || before !== (plainPassword || '')) return enc;
enc.encrypted_password = local.encrypted_password;
enc.iv = local.iv;
return enc;
}
async function encryptImportEntry(plain) { async function encryptImportEntry(plain) {
const pw = await encryptPwd(plain.password); const pw = await encryptPwd(plain.password);
let totpEnc = '', totpIv = ''; let totpEnc = '', totpIv = '';
@@ -487,11 +524,21 @@ async function encryptImportEntry(plain) {
custom_fields: cfEnc, custom_fields: cfEnc,
custom_fields_iv: cfIv, custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '', icon_b64: plain.icon_b64 || '',
icon_custom: plain.icon_custom ? 1 : 0,
// Passthrough: present in the export/backup container (restore keeps
// the flags), absent from the sync snapshot (favorite/pinned stay
// device-local by design) → undefined there, so the server writes 0.
favorite: plain.favorite ? 1 : 0,
pinned: plain.pinned ? 1 : 0,
template: plain.template || '', template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back // Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports). // to now only when these are absent (foreign CSV imports).
created_at: normalizeImportTimestamp(plain.created_at), created_at: normalizeImportTimestamp(plain.created_at),
updated_at: normalizeImportTimestamp(plain.updated_at), updated_at: normalizeImportTimestamp(plain.updated_at),
// Same passthrough contract: our containers carry the trash, the sync
// snapshot never does, a foreign CSV has no such notion → 0 = live.
deleted: plain.deleted ? 1 : 0,
deleted_at: plain.deleted ? normalizeImportTimestamp(plain.deleted_at) : '',
}); });
} }
@@ -661,8 +708,15 @@ async function doImport() {
// exists — the user is either re-importing a backup or rolling // exists — the user is either re-importing a backup or rolling
// back to an earlier version). Ask what to do with overlapping // back to an earlier version). Ask what to do with overlapping
// entries so a restore isn't silently blocked by the dedup. // entries so a restore isn't silently blocked by the dedup.
// The TRASH counts as "already in the vault". Its rows are absent from
// state.entries, so without this a container entry whose uuid is only
// in the trash was treated as fresh and inserted a SECOND row with the
// same uuid — the identity the whole sync engine relies on. Same blind
// spot as the resurrection bug in applyRemoteSnapshot (F8).
await loadTrash();
const localByUuid = new Map(); const localByUuid = new Map();
for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e); for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e);
for (const e of (state.trashed || [])) if (e && e.uuid) localByUuid.set(e.uuid, e);
const fresh = []; const fresh = [];
const overlaps = []; const overlaps = [];
for (const e of parsed.entries) { for (const e of parsed.entries) {
@@ -707,7 +761,8 @@ async function doImport() {
try { try {
const local = localByUuid.get(src.uuid); const local = localByUuid.get(src.uuid);
if (!local) continue; if (!local) continue;
const enc = await encryptImportEntry(src); const enc = await keepCiphertextIfSamePassword(
await encryptImportEntry(src), local, src.password);
await api('/entries/' + local.id, { await api('/entries/' + local.id, {
method: 'PUT', method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }), headers: authHeaders({ 'Content-Type': 'application/json' }),
@@ -716,7 +771,20 @@ async function doImport() {
// PUT ignores icon_b64 (dedicated endpoint owns it), so // PUT ignores icon_b64 (dedicated endpoint owns it), so
// restore the file's icon separately — else overwriting // restore the file's icon separately — else overwriting
// an entry whose icon was cleared never brings it back. // an entry whose icon was cleared never brings it back.
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64); if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64, !!src.icon_custom);
// favorite / pinned are the same story: PUT deliberately
// ignores them so a SYNC update can never clobber the local
// flags (they're device-local by design). But a restore
// must bring them back, so apply them here through their
// own toggle endpoints — only when they actually differ.
if (!!src.favorite !== !!local.favorite) {
await api('/entries/' + local.id + '/favorite',
{ method: 'POST', headers: authHeaders() });
}
if (!!src.pinned !== !!local.pinned) {
await api('/entries/' + local.id + '/pin',
{ method: 'POST', headers: authHeaders() });
}
overwritten++; overwritten++;
} catch (_) { /* skip the single row on failure */ } } catch (_) { /* skip the single row on failure */ }
} }
@@ -777,6 +845,9 @@ async function doImport() {
if (attachCount > 0) toast(attachCount + ' attachment(s) restored'); if (attachCount > 0) toast(attachCount + ' attachment(s) restored');
await loadEntries(); await loadEntries();
// Trash too: a container can now restore trashed entries, and
// without this the Trash count stays stale until the user opens it.
await loadTrash();
render(); render();
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
} catch (err) { } catch (err) {
@@ -787,43 +858,76 @@ async function doImport() {
fileInput.click(); fileInput.click();
} }
// One entry, decrypted and shaped for a backup container. Shared by the manual
// export and the auto-backup, which carried two copies of this loop — and would
// now need the trash handling added twice.
// `deleted` rides along so a restore rebuilds the trash instead of dropping it;
// the sync snapshot deliberately does NOT use this (see buildSyncSnapshot: the
// trash is device-local, and re-creating trashed rows duplicates them).
async function buildBackupEntry(e) {
const plain = await decryptPwd(e.encrypted_password, e.iv);
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try {
plainCustom = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
} catch (_) { plainCustom = []; }
}
// Attachments cost one extra GET per entry that has them, so vaults
// without any pay nothing. A failure here degrades to a partial export
// rather than losing the whole file.
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id, { headers: authHeaders() });
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename,
mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) {}
return {
uuid: e.uuid || '',
site: e.site,
title: e.title || '',
username: e.username,
password: plain,
folder: e.folder,
tags: parseTags(e.tags),
favorite: !!e.favorite,
pinned: !!e.pinned,
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments: attachments,
icon_b64: e.icon_b64 || '',
icon_custom: e.icon_custom ? 1 : 0,
created_at: e.created_at,
updated_at: e.updated_at,
deleted: e.deleted ? 1 : 0,
deleted_at: e.deleted_at || '',
};
}
async function doExport() { async function doExport() {
// Step 1: reauth — verifies the human in front of the screen is the // Step 1: reauth — verifies the human in front of the screen is the
// vault owner before we hand them every plaintext password. Defense // vault owner before we hand them every plaintext password. Defense
// against a stranger reaching the open laptop and exfiltrating data. // against a stranger reaching the open laptop and exfiltrating data.
// Up to 5 retries with inline error in the modal — beyond that the // reauthLoop re-prompts inline (up to 5 tries) then defers to the
// server-side rate limiter takes over (429 with retry_after). // server rate limiter; null = cancelled or capped (already toasted).
let lastError = null; if (!(await reauthLoop('Enter your master password to start an encrypted export.')))
let attempts = 0; return;
const MAX_ATTEMPTS = 5;
let reauthed = false;
while (!reauthed) {
const masterPwd = await askReauth(
'Enter your master password to start an encrypted export.',
{ error: lastError });
if (!masterPwd) return; // user cancelled
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
reauthed = true;
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
return toast('Account locked. Try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
}
attempts++;
if (attempts >= MAX_ATTEMPTS) {
return toast('Too many wrong attempts — try again later', 'error');
}
lastError = 'Wrong master password. Attempt ' +
attempts + ' / ' + MAX_ATTEMPTS + '.';
}
}
// Step 2: ask for an INDEPENDENT export password. Decoupled from the // Step 2: ask for an INDEPENDENT export password. Decoupled from the
// master pw so a master-pw change later doesn't invalidate the backup, // master pw so a master-pw change later doesn't invalidate the backup,
@@ -886,63 +990,18 @@ async function doExport() {
})), })),
entries: [], entries: [],
}; };
// Trash included: a backup that silently drops it is not a backup.
// loadTrash first — state.trashed is only populated when the user has
// visited the trash view.
await loadTrash();
const toExport = state.entries.concat(state.trashed || []);
let _expDone = 0; let _expDone = 0;
const _expTotal = state.entries.length; const _expTotal = toExport.length;
for (const e of state.entries) { for (const e of toExport) {
_expDone++; _expDone++;
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal)) if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal); updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
const plain = await decryptPwd(e.encrypted_password, e.iv); payload.entries.push(await buildBackupEntry(e));
let plainTotp = '';
if (e.totp_secret && e.totp_iv) {
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
if (plainTotp === '[ERROR]') plainTotp = '';
}
let plainCustom = [];
if (e.custom_fields && e.custom_fields_iv) {
try {
plainCustom = await decryptCustomFields(
e.custom_fields, e.custom_fields_iv);
} catch (_) { plainCustom = []; }
}
// Attachments are fetched separately (one extra GET per entry
// that has them) so this branch stays cheap on vaults without.
let attachments = [];
try {
const metas = await api('/entries/' + e.id + '/attachments',
{ headers: authHeaders() });
for (const m of (metas || [])) {
const full = await api('/attachments/' + m.id,
{ headers: authHeaders() });
const bytes = await decryptBlobBytes(
full.encrypted_blob, full.iv);
attachments.push({
filename: m.filename,
mime: m.mime,
size_bytes: m.size_bytes,
content_b64: bytesToBase64(bytes),
});
}
} catch (_) { /* partial export beats a failed one */ }
payload.entries.push({
uuid: e.uuid || '',
site: e.site,
title: e.title || '',
username: e.username,
password: plain,
folder: e.folder,
tags: parseTags(e.tags),
favorite: !!e.favorite,
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
attachments: attachments,
icon_b64: e.icon_b64 || '',
created_at: e.created_at,
updated_at: e.updated_at,
});
} }
const attachTotal = payload.entries.reduce( const attachTotal = payload.entries.reduce(
+1066 -503
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -187,6 +187,7 @@ const CHEATSHEET_GROUPS = [
items: [ items: [
{ keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' }, { keys: ['Ctrl', 'Shift', 'L'], desc: 'Autofill username + password into the active window' },
{ keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' }, { keys: ['Ctrl', 'Shift', 'P'], desc: 'Autofill password only (step-2 forms, unlock screens)' },
{ keys: ['Ctrl', 'Shift', 'T'], desc: 'Autofill the 2FA code (click the 2FA field first)' },
{ keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' }, { keys: ['Ctrl', 'Shift', 'Q'], desc: 'Quick search → SendInput password into the active window' },
{ keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' }, { keys: ['Ctrl', 'Shift', 'A'], desc: 'Quick-add a new entry pre-filled with the foreground window title' },
], ],
@@ -464,6 +465,13 @@ function endTour() {
} }
function openQuickSearchModal(hideAfter, forFill) { function openQuickSearchModal(hideAfter, forFill) {
// Same reason as the editor: the hotkey fires from outside the window, so
// the busy overlay covering the app stops nothing — the modal would open
// behind it, unreachable.
if (typeof isBusy === 'function' && isBusy()) {
toast('Busy — try again in a moment', 'warning');
return;
}
const modal = document.getElementById('quickSearchModal'); const modal = document.getElementById('quickSearchModal');
const input = document.getElementById('quickSearchInput'); const input = document.getElementById('quickSearchInput');
modal.classList.remove('is-hidden'); modal.classList.remove('is-hidden');
@@ -501,7 +509,12 @@ function closeQuickSearchModal(keepClipboard) {
if (quickSearchHideAfter) { if (quickSearchHideAfter) {
quickSearchHideAfter = false; quickSearchHideAfter = false;
if (Bridge.active && typeof Bridge.minimizeToTray === 'function') if (Bridge.active && typeof Bridge.minimizeToTray === 'function')
Bridge.minimizeToTray(!!keepClipboard); // Deferred: in fill mode the cancelAutofill above already fired a
// cmd:// navigation, and two back-to-back window.location.href
// assignments COALESCE — only one survives, so the minimise was
// silently dropped and the app stayed on screen. Same pitfall as
// the quickunlock/store + pin/clear pair in doChangeMasterPassword.
setTimeout(() => Bridge.minimizeToTray(!!keepClipboard), 0);
} }
} }
+197 -18
View File
@@ -43,8 +43,54 @@ Bridge.onWebdavResult = function(reqId, status, payload, etag) {
delete _webdavResolvers[reqId]; delete _webdavResolvers[reqId];
r({ status: status | 0, payload: payload || '', etag: etag || '' }); r({ status: status | 0, payload: payload || '', etag: etag || '' });
}; };
// A Gitea repository, recognised by its contents-API path — the same
// discriminant the Android client uses, so one URL tells both ends which
// transport to speak. Everything below that differs is confined to the two
// call sites in runSyncNow(); the bridge itself is shared.
function syncIsGitea(url) {
return (url || '').toLowerCase().includes('/api/v1/repos/');
}
// Unwraps a Gitea contents response into { container, sha }. The API answers
// JSON whose `content` is the file in base64 — so the payload is base64 twice
// over: once by our bridge, once by Gitea. Returns null if the JSON is not
// the shape we expect, which is what a slightly-wrong URL produces: valid
// JSON, but not this.
function giteaUnwrap(bodyText) {
let obj;
try { obj = JSON.parse(bodyText); } catch (e) { return null; }
if (!obj || !obj.content || !obj.sha) return null;
if (obj.encoding && obj.encoding !== 'base64') return null;
try {
const raw = obj.content.replace(/[\r\n]/g, ''); // Gitea wraps its base64
return { container: new TextDecoder().decode(base64ToBytes(raw)), sha: obj.sha };
} catch (e) { return null; }
}
// Do these two snapshots carry the same vault? Compared as plaintext, because
// the ciphertext never repeats — a fresh salt and IV per encryption make two
// containers of an identical vault differ completely.
//
// `snapshot_at` is stripped: it is the time of the build, not part of what the
// vault contains, and keeping it would make every comparison fail. Everything
// else is compared verbatim, which is deliberately strict — a difference in key
// order or in a field we have not thought about falls on the side of pushing,
// which costs a commit rather than losing a change.
function syncSnapshotsEqual(a, b) {
if (!a || !b) return false;
const strip = s => {
const c = Object.assign({}, s);
delete c.snapshot_at;
delete c.exported_at;
return JSON.stringify(c);
};
try { return strip(a) === strip(b); } catch (e) { return false; }
}
// method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so // method: 'get'|'put'|'test'. opts.ifMatch → sent as If-Match on a put so
// the server rejects (412) a write when the remote changed since our pull. // the server rejects (412) a write when the remote changed since our pull.
// On Gitea the sha travels inside the PUT body instead, and the bridge sends
// no If-Match.
function _webdavCall(method, url, user, pwd, dataB64, opts) { function _webdavCall(method, url, user, pwd, dataB64, opts) {
opts = opts || {}; opts = opts || {};
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
@@ -106,10 +152,16 @@ function refreshSyncUI(cfg) {
const pre = document.getElementById('syncPreBackup'); const pre = document.getElementById('syncPreBackup');
if (url) url.value = cfg.url || ''; if (url) url.value = cfg.url || '';
if (user) user.value = cfg.user || ''; if (user) user.value = cfg.user || '';
if (pwd) pwd.value = cfg.pwd || ''; if (pwd) {
// Left EMPTY on purpose — the settings panel no longer pulls the stored
// password across. The field only saves on `change`, so an untouched
// empty field never overwrites anything; the placeholder says so.
pwd.value = cfg.pwd || '';
pwd.placeholder = cfg.hasPwd ? 'Saved — type to replace' : '';
}
if (pre) pre.checked = !!cfg.preBackup; if (pre) pre.checked = !!cfg.preBackup;
const pwdStatus = document.getElementById('syncPwdStatus'); const pwdStatus = document.getElementById('syncPwdStatus');
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.'; if (pwdStatus) pwdStatus.textContent = cfg.hasEncPwd ? 'Set.' : 'Not set.';
const last = document.getElementById('syncLast'); const last = document.getElementById('syncLast');
if (last) last.textContent = cfg.last if (last) last.textContent = cfg.last
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16) ? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
@@ -146,16 +198,23 @@ async function syncSetEncPwdFlow() {
} }
} }
async function loadSyncConfig() { // withSecrets defaults to FALSE: the settings panel only needs to know whether
// the two passwords are set. Fetching them plants the values in the renderer
// for the life of the process — seen in a memory dump, still present after the
// vault had auto-locked. Only the paths that actually talk to the server or
// encrypt a snapshot ask for them.
async function loadSyncConfig(withSecrets) {
if (!Bridge.active) return null; if (!Bridge.active) return null;
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([ const [enabled, url, user, pwd, encPwd, pre, last, hasPwd, hasEnc] = await Promise.all([
Bridge.getPref(SYNC_PREFS.enabled), Bridge.getPref(SYNC_PREFS.enabled),
Bridge.getPref(SYNC_PREFS.url), Bridge.getPref(SYNC_PREFS.url),
Bridge.getPref(SYNC_PREFS.user), Bridge.getPref(SYNC_PREFS.user),
Bridge.getPref(SYNC_PREFS.pwd), withSecrets ? Bridge.getPref(SYNC_PREFS.pwd) : Promise.resolve(''),
Bridge.getPref(SYNC_PREFS.encPwd), withSecrets ? Bridge.getPref(SYNC_PREFS.encPwd) : Promise.resolve(''),
Bridge.getPref(SYNC_PREFS.preBackup), Bridge.getPref(SYNC_PREFS.preBackup),
Bridge.getPref(SYNC_PREFS.last), Bridge.getPref(SYNC_PREFS.last),
Bridge.hasPref(SYNC_PREFS.pwd),
Bridge.hasPref(SYNC_PREFS.encPwd),
]); ]);
return { return {
enabled: enabled === '1', enabled: enabled === '1',
@@ -163,16 +222,31 @@ async function loadSyncConfig() {
user: user || '', user: user || '',
pwd: pwd || '', pwd: pwd || '',
encPwd: encPwd || '', encPwd: encPwd || '',
hasPwd: !!hasPwd,
hasEncPwd: !!hasEnc,
preBackup: pre === '1', preBackup: pre === '1',
last: last || '', last: last || '',
}; };
} }
async function syncTestConnection() { async function syncTestConnection() {
const cfg = await loadSyncConfig(); const cfg = await loadSyncConfig(true); // needs the WebDAV credentials
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning'); if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
toast('Testing connection…'); toast('Testing connection…');
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd); const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
// A reachable Gitea with a valid token is not enough: a URL pointing one
// path segment off answers 200 with JSON that is not a file. Checking the
// shape here is what makes the test worth pressing.
if (syncIsGitea(cfg.url) && r.status >= 200 && r.status < 300) {
let un = null;
try {
un = giteaUnwrap(new TextDecoder().decode(base64ToBytes(r.payload || '')));
} catch (e) { /* traite comme une forme inattendue */ }
return un
? toast('Connection OK · file found in the repository')
: toast('Reached Gitea, but this URL does not return a file — ' +
'expected .../api/v1/repos/OWNER/REPO/contents/FILE', 'error');
}
if (r.status >= 200 && r.status < 400) { if (r.status >= 200 && r.status < 400) {
toast('Connection OK (' + r.status + ')'); toast('Connection OK (' + r.status + ')');
} else if (r.status === 404) { } else if (r.status === 404) {
@@ -229,6 +303,29 @@ async function buildSyncSnapshot() {
e.custom_fields, e.custom_fields_iv); } e.custom_fields, e.custom_fields_iv); }
catch (_) {} catch (_) {}
} }
// TROIS ANCIENNES VERSIONS, PAS VINGT.
//
// La table en garde vingt, et c'est bien sur le poste : elles y restent
// chiffrees colonne par colonne et ne bougent jamais. Dans l'instantane
// tout voyage EN CLAIR a l'interieur du conteneur, et redescend dans le
// cache d'un telephone que seule la cle de l'appareil scelle - pas le
// mot de passe maitre. Un ancien mot de passe reste sensible : il est
// souvent encore valide ailleurs.
//
// L'usage reel de cette liste est court - "je l'ai change recemment,
// quel etait l'ancien ?" - et trois le couvrent. Les dix-sept autres
// seraient des copies supplementaires sans besoin en face.
let history = [];
try {
const hist = await api('/entries/' + e.id + '/history',
{ headers: authHeaders() });
for (const h of (hist || []).slice(0, 3)) {
const old = await decryptPwd(h.encrypted_password, h.iv);
if (old === '[ERROR]') continue;
history.push({ password: old, changed_at: h.changed_at });
}
} catch (_) {}
let attachments = []; let attachments = [];
try { try {
const metas = await api('/entries/' + e.id + '/attachments', const metas = await api('/entries/' + e.id + '/attachments',
@@ -252,15 +349,24 @@ async function buildSyncSnapshot() {
password: plain === '[ERROR]' ? '' : plain, password: plain === '[ERROR]' ? '' : plain,
folder: e.folder || 'All', folder: e.folder || 'All',
tags: parseTags(e.tags), tags: parseTags(e.tags),
favorite: !!e.favorite, // favorite / pinned deliberately OMITTED — device-local by design
// (decided 2026-07-11, like the avatar). encryptImportEntry passes
// through whatever the container holds, so leaving them out here
// is what keeps them from propagating. Adding them back = they sync.
totp_secret: plainTotp, totp_secret: plainTotp,
kind: e.kind || 'login', kind: e.kind || 'login',
template: e.template || '', template: e.template || '',
custom_fields: plainCustom, custom_fields: plainCustom,
attachments, attachments,
icon_b64: e.icon_b64 || '', icon_b64: e.icon_b64 || '',
icon_custom: e.icon_custom ? 1 : 0,
created_at: e.created_at, created_at: e.created_at,
updated_at: e.updated_at, updated_at: e.updated_at,
// Lecture seule pour les autres appareils : la table du poste est
// la source, et applyRemoteSnapshot ignore ce champ. Il survit aux
// ecritures du telephone parce que celui-ci modifie le JSON recu
// sans jamais le reserialiser.
password_history: history,
}); });
} }
try { try {
@@ -330,6 +436,17 @@ async function applyRemoteSnapshot(remote) {
const byUuid = new Map(); const byUuid = new Map();
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e); for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
// Entries sitting in the TRASH are soft-deleted: no tombstone is written
// (that only happens on a hard delete), and they are absent from
// state.entries. Without this set the merge sees their uuids as "missing
// locally" and POSTs them back as fresh live rows — moving 30 entries to
// the trash then syncing produced 30 duplicates, live copies alongside the
// trashed originals, two rows sharing one uuid. The local decision to trash
// wins; emptying the trash writes the tombstone that propagates the delete.
await loadTrash();
const trashedUuids = new Set();
for (const e of (state.trashed || [])) if (e.uuid) trashedUuids.add(e.uuid);
// Local tombstones must veto ANY remote entry with a matching uuid — // Local tombstones must veto ANY remote entry with a matching uuid —
// otherwise a perm-delete on this device gets undone by the next // otherwise a perm-delete on this device gets undone by the next
// pull ("48 added" after wiping the vault, because the remote // pull ("48 added" after wiping the vault, because the remote
@@ -382,6 +499,9 @@ async function applyRemoteSnapshot(remote) {
// Skip entries this device has already tombstoned — never // Skip entries this device has already tombstoned — never
// resurrect a deleted entry. // resurrect a deleted entry.
if (localTombstones.has(r.uuid)) continue; if (localTombstones.has(r.uuid)) continue;
// Same for the trash: the entry exists locally, just not in the live
// list. Re-creating it would duplicate it, not restore it.
if (trashedUuids.has(r.uuid)) continue;
const local = byUuid.get(r.uuid); const local = byUuid.get(r.uuid);
if (!local) { if (!local) {
// New entry on remote — encrypt locally + POST keeping the uuid. // New entry on remote — encrypt locally + POST keeping the uuid.
@@ -419,7 +539,11 @@ async function applyRemoteSnapshot(remote) {
const remoteWins = (r.updated_at || '') > (local.updated_at || ''); const remoteWins = (r.updated_at || '') > (local.updated_at || '');
if (!remoteWins) continue; if (!remoteWins) continue;
try { try {
const enc = await encryptImportEntry(r); // Le chiffré d'origine si le mot de passe n'a pas bougé :
// sans cela le serveur archive une version d'historique à
// chaque synchronisation. Voir keepCiphertextIfSamePassword.
const enc = await keepCiphertextIfSamePassword(
await encryptImportEntry(r), local, r.password);
// PUT keeps the existing id but accepts the encrypted blobs. // PUT keeps the existing id but accepts the encrypted blobs.
// template + uuid not touched here (server preserves columns // template + uuid not touched here (server preserves columns
// when fields aren't in body — uuid is immutable anyway). // when fields aren't in body — uuid is immutable anyway).
@@ -460,7 +584,7 @@ function syncStatus(text) {
async function runSyncNow(_attempt) { async function runSyncNow(_attempt) {
_attempt = _attempt || 0; _attempt = _attempt || 0;
const cfg = await loadSyncConfig(); const cfg = await loadSyncConfig(true); // talks to the server + encrypts
if (!cfg) return toast('Bridge not available', 'error'); if (!cfg) return toast('Bridge not available', 'error');
if (!cfg.url) return toast('Sync not configured', 'warning'); if (!cfg.url) return toast('Sync not configured', 'warning');
if (!cfg.encPwd) return toast('Set the sync password first', 'warning'); if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
@@ -493,14 +617,32 @@ async function runSyncNow(_attempt) {
// ETag of the version we just pulled — sent back as If-Match on the // ETag of the version we just pulled — sent back as If-Match on the
// push so the server rejects our write if another device changed the // push so the server rejects our write if another device changed the
// file in between (optimistic concurrency, avoids lost updates). // file in between (optimistic concurrency, avoids lost updates).
const remoteEtag = pullResp.etag || ''; let remoteEtag = pullResp.etag || '';
// Decrypt + cross-account guard BEFORE touching local state, so a // Decrypt + cross-account guard BEFORE touching local state, so a
// wrong sync password or a foreign account aborts cleanly. // wrong sync password or a foreign account aborts cleanly.
let remoteSnap = null; let remoteSnap = null;
if (pullResp.status !== 404 && pullResp.payload) { if (pullResp.status !== 404 && pullResp.payload) {
let jsonText;
try {
jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
} catch (e) {
syncStatus('');
return toast('Unreadable reply from the remote', 'error');
}
if (syncIsGitea(cfg.url)) {
const un = giteaUnwrap(jsonText);
if (!un) {
syncStatus('');
return toast('Gitea replied without content/sha — is the URL a ' +
'contents-API path to the file?', 'error');
}
jsonText = un.container;
// The blob sha takes the ETag's place, and plays exactly its role:
// it goes back on the push, and a stale one is refused.
remoteEtag = un.sha;
}
try { try {
const jsonText = new TextDecoder().decode(base64ToBytes(pullResp.payload));
const container = JSON.parse(jsonText); const container = JSON.parse(jsonText);
remoteSnap = await decryptExportContainer(container, cfg.encPwd); remoteSnap = await decryptExportContainer(container, cfg.encPwd);
} catch (e) { } catch (e) {
@@ -578,19 +720,56 @@ async function runSyncNow(_attempt) {
await loadEntries(); // pull latest after applying remote changes await loadEntries(); // pull latest after applying remote changes
const snap = await buildSyncSnapshot(); const snap = await buildSyncSnapshot();
pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0; pushedCount = Array.isArray(snap.entries) ? snap.entries.length : 0;
// RIEN A DIRE, RIEN A ECRIRE. Un push a lieu meme quand la fusion n'a
// rien change, et il ne passe pas inapercu : le conteneur est rechiffre
// avec un sel et un IV neufs, donc son contenu differe a chaque fois
// meme a coffre identique. Sur Gitea cela vaut un commit de plus et un
// nouveau sha ; les autres appareils voient alors un instantane
// "nouveau" et paient une derivation de cle complete - mesuree a 5,7 s
// sur le telephone - pour retrouver exactement ce qu'ils avaient deja.
//
// La comparaison porte sur le CLAIR, seul endroit ou l'egalite est
// observable, et ignore `snapshot_at`, qui change par construction.
if (remoteSnap && syncSnapshotsEqual(remoteSnap, snap)) {
syncStatus('');
// La synchronisation a bien eu lieu, et l'horodatage le dit : ne
// rien avoir a ecrire est une reussite, pas une absence.
Bridge.setPref(SYNC_PREFS.last, new Date().toISOString());
render();
return toast('Already up to date · ' + pushedCount + ' entries');
}
syncStatus('Encrypting…'); syncStatus('Encrypting…');
const container = await encryptExportPayload(snap, cfg.encPwd); const container = await encryptExportPayload(snap, cfg.encPwd);
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2)); const containerBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
syncStatus('Pushing…'); syncStatus('Pushing…');
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, let bodyB64, opts;
bytesToBase64(bodyBytes), if (syncIsGitea(cfg.url)) {
{ ifMatch: remoteEtag }); // The file rides inside a JSON envelope, base64 again. `sha` is
// OMITTED rather than empty on a first push: Gitea reads its
// presence as "replace that exact version", and an empty one is
// not the same as none. `branch` is omitted too, so the repo's
// default branch is used instead of a name we would be guessing.
const env = {
message: 'sync from ' + (state.username || 'desktop'),
content: bytesToBase64(containerBytes)
};
if (remoteEtag) env.sha = remoteEtag;
bodyB64 = bytesToBase64(new TextEncoder().encode(JSON.stringify(env)));
opts = {};
} else {
bodyB64 = bytesToBase64(containerBytes);
opts = { ifMatch: remoteEtag };
}
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd, bodyB64, opts);
// 412 Precondition Failed = the remote changed since our pull // 412 Precondition Failed = the remote changed since our pull
// (another device pushed). Re-run the whole pull→merge→push so we // (another device pushed). Re-run the whole pull→merge→push so we
// fold in their changes instead of clobbering them. Bounded to a // fold in their changes instead of clobbering them. Bounded to a
// few tries to avoid a livelock against a device syncing in a hot // few tries to avoid a livelock against a device syncing in a hot
// loop. // loop. Gitea says the same thing with 409: the sha we sent is no
if (r.status === 412) { // longer the file's.
if (r.status === 412 || r.status === 409) {
syncStatus(''); syncStatus('');
if (_attempt < 3) { if (_attempt < 3) {
toast('Remote changed — re-syncing…'); toast('Remote changed — re-syncing…');
+37 -52
View File
@@ -277,20 +277,9 @@ async function pinSetupFlow() {
// Setting/changing a PIN creates a new unlock path → treat as a // Setting/changing a PIN creates a new unlock path → treat as a
// sensitive operation. An unattended unlocked vault must not be // sensitive operation. An unattended unlocked vault must not be
// possible for a passerby to pin-backdoor. // possible for a passerby to pin-backdoor.
const masterPwd = await askReauth( const masterPwd = await reauthLoop(
'Confirm your master password to set or change the PIN.'); 'Confirm your master password to set or change the PIN.');
if (!masterPwd) return; if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
let lastError = ''; let lastError = '';
let attempts = 0; let attempts = 0;
@@ -401,7 +390,6 @@ async function loginViaPin(pin) {
sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false; state.locked = false;
state.justRecovered = false; state.justRecovered = false;
@@ -420,20 +408,9 @@ async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning'); if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning'); if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const masterPwd = await askReauth( const masterPwd = await reauthLoop(
'Confirm your master password to enable Quick unlock on this device.'); 'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return; if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
// Export the raw key + identity. We don't store the session token — // Export the raw key + identity. We don't store the session token —
// tryQuickUnlock re-logs in with a verifier derived from the key, // tryQuickUnlock re-logs in with a verifier derived from the key,
@@ -547,7 +524,6 @@ async function tryQuickUnlock() {
sessionStorage.setItem('hashAlgo', state.hashAlgo); sessionStorage.setItem('hashAlgo', state.hashAlgo);
sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('csrfToken', state.csrf);
await persistCryptoKey();
state.locked = false; state.locked = false;
return true; return true;
@@ -676,7 +652,10 @@ async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCod
async function doGenerateRecoveryKey() { async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); } if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth( // Pre-verify via reauthLoop (inline retry) so a wrong master pw re-prompts
// instead of failing the setup POST and closing the modal. The setup call
// below still carries the verifier — the server re-checks it.
const masterPwd = await reauthLoop(
'Confirm your master password to generate a recovery key.'); 'Confirm your master password to generate a recovery key.');
if (!masterPwd) return; if (!masterPwd) return;
@@ -693,29 +672,12 @@ async function doGenerateRecoveryKey() {
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt); rawKey, code, kdfSalt);
try { // Everything above is local — NOTHING is written server-side yet. The
// Send a verifier instead of the master pw — server proves the // setup POST (which deletes any existing recovery row and inserts the new
// user still knows the master pw without ever seeing the plaintext. // one) is deferred until AFTER the user acknowledges they saved the code.
const verifier = await computeVerifier( // Committing before the modal meant backing out still erased the old code
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); // and activated a new one the user never saw → lockout. Cancel now leaves
await api('/recovery-key/setup', { // the account exactly as it was.
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
return toast('Setup failed: ' + (err.message || ''), 'error');
}
// Refresh the Settings button label
state.recoveryConfigured = true;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
// Show the code ONCE. Use the confirm modal so the user has to // Show the code ONCE. Use the confirm modal so the user has to
// explicitly click "I saved it" before the value vanishes. // explicitly click "I saved it" before the value vanishes.
@@ -748,7 +710,7 @@ async function doGenerateRecoveryKey() {
} }
}, 0); }, 0);
await confirmDialog({ const saved = await confirmDialog({
title: 'Your recovery code', title: 'Your recovery code',
message: '<p>Save this code somewhere safe (password manager, ' + message: '<p>Save this code somewhere safe (password manager, ' +
'safe deposit box, printed copy). It will <b>not</b> be ' + 'safe deposit box, printed copy). It will <b>not</b> be ' +
@@ -772,7 +734,31 @@ async function doGenerateRecoveryKey() {
'your master password — so set a new one right after ' + 'your master password — so set a new one right after ' +
'recovering.</p>', 'recovering.</p>',
okText: 'I saved it', okText: 'I saved it',
cancelText: 'Cancel',
}); });
if (!saved) return toast('Cancelled — no recovery code was set');
// Acknowledged → commit. reauthLoop already verified the master pw; the
// server re-checks the verifier below.
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
verifier: verifier,
codeHash: codeHash,
kdfSalt: kdfSalt,
wrappedKey: wrappedKey,
wrappedIv: wrappedIv,
}),
});
} catch (err) {
return toast('Setup failed: ' + (err.message || ''), 'error');
}
state.recoveryConfigured = true;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
toast('Recovery code generated'); toast('Recovery code generated');
} }
@@ -891,7 +877,6 @@ async function doRecoveryRedeem() {
// so master-pw change can later re-export and re-wrap as needed). // so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey( state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']); 'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0; const remaining = (typeof r.remainingUses === 'number') ? r.remainingUses : 0;
if (remaining <= 0) { if (remaining <= 0) {
+75
View File
@@ -0,0 +1,75 @@
// Le transport Gitea : reconnaissance de l'URL, et lecture de la reponse de
// l'API des contenus.
//
// POURQUOI CES DEUX-LA. `giteaUnwrap` est le seul endroit du chemin de sync qui
// interprete du JSON venu du reseau, et il decide de ce qui sera ensuite
// dechiffre. Une URL a un segment pres rend du JSON valide qui n'est PAS un
// fichier ; s'il passait, l'erreur ne se verrait qu'au message
// « mot de passe de sync incorrect », qui envoie chercher la cause ailleurs.
//
// Le `sha` compte autant que le contenu : il repart en `sha` au push, et c'est
// lui qui fait refuser une ecriture concurrente. Le rendre vide plutot
// qu'absent ferait ecraser la version d'un autre appareil.
const test = require('node:test');
const assert = require('node:assert');
const { loadApp } = require('./harness');
function b64(text) {
return Buffer.from(text, 'utf8').toString('base64');
}
test('syncIsGitea: recognises a contents-API URL, ignores WebDAV', async () => {
const { __test: t } = await loadApp();
assert.equal(t.syncIsGitea(
'https://git.example.com/api/v1/repos/Zaki/pm_sync/contents/vault-sync.json'), true);
// Casse indifferente : l'URL est saisie a la main.
assert.equal(t.syncIsGitea(
'https://git.example.com/API/V1/REPOS/Zaki/pm_sync/contents/v.json'), true);
// Une URL de depot n'est pas une URL d'API, et elle ne rendrait pas de sha.
assert.equal(t.syncIsGitea('https://git.example.com/Zaki/pm_sync'), false);
assert.equal(t.syncIsGitea('https://dav.example.com/remote.php/vault.json'), false);
assert.equal(t.syncIsGitea(''), false);
assert.equal(t.syncIsGitea(undefined), false);
});
test('giteaUnwrap: returns the file and its sha', async () => {
const { __test: t } = await loadApp();
const container = '{"version":1,"cipher":"AAAA"}';
const un = t.giteaUnwrap(JSON.stringify({
content: b64(container), encoding: 'base64', sha: 'abc123', name: 'vault-sync.json'
}));
assert.ok(un, 'devrait accepter une reponse bien formee');
assert.equal(un.container, container);
assert.equal(un.sha, 'abc123');
});
test('giteaUnwrap: accepts the line-wrapped base64 Gitea sends', async () => {
const { __test: t } = await loadApp();
const container = '{"version":1}';
// Gitea coupe son base64 en lignes ; un decodeur strict s'y casse.
const wrapped = b64(container).replace(/(.{4})/, '$1\r\n');
const un = t.giteaUnwrap(JSON.stringify({
content: wrapped, encoding: 'base64', sha: 'deadbeef'
}));
assert.ok(un, 'les retours a la ligne ne doivent pas faire echouer le decodage');
assert.equal(un.container, container);
});
test('giteaUnwrap: refuses anything that is not a file', async () => {
const { __test: t } = await loadApp();
// Ce que rend une URL a un segment pres : du JSON valide, sans fichier.
assert.equal(t.giteaUnwrap('{"message":"Not found"}'), null);
// Un repertoire : l'API rend un TABLEAU, pas un objet.
assert.equal(t.giteaUnwrap('[{"name":"vault-sync.json","sha":"x"}]'), null);
// Un sha sans contenu ne permet rien de dechiffrer.
assert.equal(t.giteaUnwrap('{"sha":"abc"}'), null);
// Un contenu sans sha interdirait l'ecriture concurrente : refuse aussi.
assert.equal(t.giteaUnwrap(JSON.stringify({ content: b64('{}') })), null);
// Un encodage que nous ne savons pas lire.
assert.equal(t.giteaUnwrap(JSON.stringify({
content: 'x', encoding: 'utf-8', sha: 'abc' })), null);
// Pas du JSON du tout - une page d'erreur HTML, par exemple.
assert.equal(t.giteaUnwrap('<html>502</html>'), null);
assert.equal(t.giteaUnwrap(''), null);
});
+2
View File
@@ -152,6 +152,8 @@ function loadApp(overrides = {}) {
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined), computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below) // merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot, applyRemoteSnapshot, buildSyncSnapshot,
// gitea transport
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest // metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS, withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
}; };
+65
View File
@@ -0,0 +1,65 @@
// L'egalite de deux instantanes, qui decide si un push a lieu.
//
// POURQUOI C'EST TESTE. Un faux positif est le cas dangereux : il ferait taire
// un push qui portait une modification, et la perdrait pour les autres
// appareils. La comparaison est donc volontairement stricte, et ces cas fixent
// ou est la limite. Un faux negatif ne coute qu'un commit inutile.
//
// `snapshot_at` doit etre ignore : il change a chaque construction, et sans cela
// deux instantanes identiques ne seraient jamais egaux - le correctif ne
// servirait a rien.
const test = require('node:test');
const assert = require('node:assert');
const { loadApp } = require('./harness');
function snap(extra) {
return Object.assign({
version: 1,
snapshot_at: '2026-08-17T10:00:00.000Z',
username: 'test',
folders: [{ name: 'folder test' }],
entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-01T00:00:00Z' }],
tombstones: []
}, extra || {});
}
test('syncSnapshotsEqual: identical vaults are equal despite the build time', async () => {
const { __test: t } = await loadApp();
const a = snap();
const b = snap({ snapshot_at: '2026-08-17T15:36:12.000Z' });
assert.equal(t.syncSnapshotsEqual(a, b), true);
});
test('syncSnapshotsEqual: exported_at is ignored too', async () => {
const { __test: t } = await loadApp();
assert.equal(t.syncSnapshotsEqual(
snap({ exported_at: '2026-01-01T00:00:00Z' }),
snap({ exported_at: '2026-08-17T00:00:00Z' })), true);
});
test('syncSnapshotsEqual: any real difference must push', async () => {
const { __test: t } = await loadApp();
// Une entree modifiee.
assert.equal(t.syncSnapshotsEqual(snap(), snap({
entries: [{ uuid: 'u1', title: 'b', updated_at: '2026-08-01T00:00:00Z' }] })), false);
// Un horodatage d'entree plus recent, meme titre : c'est ce qui arbitre la
// fusion chez les autres, donc ca doit voyager.
assert.equal(t.syncSnapshotsEqual(snap(), snap({
entries: [{ uuid: 'u1', title: 'a', updated_at: '2026-08-17T00:00:00Z' }] })), false);
// Une entree en plus, une en moins.
assert.equal(t.syncSnapshotsEqual(snap(), snap({ entries: [] })), false);
// Un dossier renomme.
assert.equal(t.syncSnapshotsEqual(snap(), snap({ folders: [{ name: 'autre' }] })), false);
// Une pierre tombale : la suppression doit se propager.
assert.equal(t.syncSnapshotsEqual(snap(), snap({
tombstones: [{ uuid: 'u9', deleted_at: '2026-08-17T00:00:00Z' }] })), false);
});
test('syncSnapshotsEqual: a missing side is never equal', async () => {
const { __test: t } = await loadApp();
// 404 distant : il n'y a rien la-bas, donc il faut ecrire.
assert.equal(t.syncSnapshotsEqual(null, snap()), false);
assert.equal(t.syncSnapshotsEqual(snap(), null), false);
assert.equal(t.syncSnapshotsEqual(null, null), false);
});
+11
View File
@@ -0,0 +1,11 @@
object Form8: TForm8
Left = 0
Top = 0
Caption = 'Form8'
ClientHeight = 480
ClientWidth = 640
FormFactor.Width = 320
FormFactor.Height = 480
FormFactor.Devices = [Desktop, iPhone, iPad]
DesignerMasterStyle = 0
end
+3233
View File
File diff suppressed because it is too large Load Diff
View File
+384
View File
@@ -0,0 +1,384 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Condition="Exists('$(BDS)\bin\CodeGear.Deployment.targets')" Project="$(BDS)\bin\CodeGear.Deployment.targets"/>
<ProjectExtensions>
<ProjectFileVersion>12</ProjectFileVersion>
</ProjectExtensions>
<PropertyGroup>
<DeviceId Condition="'$(Platform)'=='Android'">13c0f9d8</DeviceId>
<DeviceId Condition="'$(Platform)'=='Android64'">13c0f9d8</DeviceId>
<DeviceId Condition="'$(Platform)'=='iOSDevice64'"/>
<DeviceId Condition="'$(Platform)'=='iOSSimARM64'"/>
</PropertyGroup>
<ItemGroup Condition="'$(Platform)'=='OSX64'"/>
<ItemGroup Condition="'$(Platform)'=='Win32'">
<DeployFile Include="Z:\Win32\Debug\pmserverApk.exe" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\</RemoteDir>
<RemoteName>pmserverApk.exe</RemoteName>
<DeployClass>ProjectOutput</DeployClass>
<Operation>0</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
<Required>True</Required>
</DeployFile>
</ItemGroup>
<ItemGroup Condition="'$(Platform)'=='OSXARM64'"/>
<ItemGroup Condition="'$(Platform)'=='Android'"/>
<ItemGroup Condition="'$(Platform)'=='Win64'"/>
<ItemGroup Condition="'$(Platform)'=='Android64'">
<DeployFile Include="Android64\Debug\pmserverApk.classes" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\classes\</RemoteDir>
<RemoteName>pmserverApk.classes</RemoteName>
<DeployClass>AndroidClasses</DeployClass>
<Operation>64</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashV31.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-anydpi-v31\</RemoteDir>
<RemoteName>splash_vector.xml</RemoteName>
<DeployClass>Android_VectorizedSplashV31</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\ic_launcher.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-anydpi-v26\</RemoteDir>
<RemoteName>ic_launcher.xml</RemoteName>
<DeployClass>Android_AdaptiveIcon</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-hdpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon72</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashDark.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-night-anydpi-v21\</RemoteDir>
<RemoteName>splash_vector.xml</RemoteName>
<DeployClass>Android_VectorizedSplashDark</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\AndroidManifest.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\</RemoteDir>
<RemoteName>AndroidManifest.xml</RemoteName>
<DeployClass>ProjectAndroidManifest</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-mdpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon48</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xxhdpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon144</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xxxhdpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon192</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\colors-night-v21.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values-night-v21\</RemoteDir>
<RemoteName>colors.xml</RemoteName>
<DeployClass>Android_ColorsDark</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xhdpi\</RemoteDir>
<RemoteName>ic_notification.png</RemoteName>
<DeployClass>Android_NotificationIcon48</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-large\</RemoteDir>
<RemoteName>splash_image.png</RemoteName>
<DeployClass>Android_SplashImage640</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Monochrome.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
<RemoteName>ic_launcher_monochrome.xml</RemoteName>
<DeployClass>Android_AdaptiveIconMonochrome</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\splash_image_def-v21.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-anydpi-v21\</RemoteDir>
<RemoteName>splash_image_def.xml</RemoteName>
<DeployClass>AndroidSplashImageDefV21</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\lib\android\debug\armeabi\libnative-activity.so" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\library\lib\armeabi\</RemoteDir>
<RemoteName>libpmserverApk.so</RemoteName>
<DeployClass>AndroidLibnativeArmeabiFile</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\styles-v31.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values-v31\</RemoteDir>
<RemoteName>styles.xml</RemoteName>
<DeployClass>AndroidSplashStylesV31</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\colors.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values\</RemoteDir>
<RemoteName>colors.xml</RemoteName>
<DeployClass>Android_Colors</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\splash_image_def.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
<RemoteName>splash_image_def.xml</RemoteName>
<DeployClass>AndroidSplashImageDef</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-hdpi\</RemoteDir>
<RemoteName>ic_notification.png</RemoteName>
<DeployClass>Android_NotificationIcon36</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xxxhdpi\</RemoteDir>
<RemoteName>ic_notification.png</RemoteName>
<DeployClass>Android_NotificationIcon96</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\lib\android\debug\mips\libnative-activity.so" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\library\lib\mips\</RemoteDir>
<RemoteName>libpmserverApk.so</RemoteName>
<DeployClass>AndroidLibnativeMipsFile</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xhdpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon96</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplash.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
<RemoteName>splash_vector.xml</RemoteName>
<DeployClass>Android_VectorizedSplash</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\lib\android\debug\armeabi-v7a\libnative-activity.so" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\library\lib\armeabi-v7a\</RemoteDir>
<RemoteName>libpmserverApk.so</RemoteName>
<DeployClass>AndroidLibnativeArmeabiv7aFile</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-mdpi\</RemoteDir>
<RemoteName>ic_notification.png</RemoteName>
<DeployClass>Android_NotificationIcon24</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-ldpi\</RemoteDir>
<RemoteName>ic_launcher.png</RemoteName>
<DeployClass>Android_LauncherIcon36</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xlarge\</RemoteDir>
<RemoteName>splash_image.png</RemoteName>
<DeployClass>Android_SplashImage960</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedSplashV31Dark.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-night-anydpi-v31\</RemoteDir>
<RemoteName>splash_vector.xml</RemoteName>
<DeployClass>Android_VectorizedSplashV31Dark</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\styles.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values\</RemoteDir>
<RemoteName>styles.xml</RemoteName>
<DeployClass>AndroidSplashStyles</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-small\</RemoteDir>
<RemoteName>splash_image.png</RemoteName>
<DeployClass>Android_SplashImage426</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_VectorizedNotificationIcon.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-anydpi-v24\</RemoteDir>
<RemoteName>ic_notification.xml</RemoteName>
<DeployClass>Android_VectorizedNotificationIcon</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\strings.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values\</RemoteDir>
<RemoteName>strings.xml</RemoteName>
<DeployClass>Android_Strings</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\styles-v21.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\values-v21\</RemoteDir>
<RemoteName>styles.xml</RemoteName>
<DeployClass>AndroidSplashStylesV21</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\libpmserverApk.so" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\library\lib\arm64-v8a\</RemoteDir>
<RemoteName>libpmserverApk.so</RemoteName>
<DeployClass>ProjectOutput</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
<Required>True</Required>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-xxhdpi\</RemoteDir>
<RemoteName>ic_notification.png</RemoteName>
<DeployClass>Android_NotificationIcon72</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Foreground.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
<RemoteName>ic_launcher_foreground.xml</RemoteName>
<DeployClass>Android_AdaptiveIconForeground</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_AdaptiveIcon_Background.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable\</RemoteDir>
<RemoteName>ic_launcher_background.xml</RemoteName>
<DeployClass>Android_AdaptiveIconBackground</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="Android64\Debug\ic_launcher-v33.xml" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-anydpi-v33\</RemoteDir>
<RemoteName>ic_launcher.xml</RemoteName>
<DeployClass>Android_AdaptiveIconV33</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
<DeployFile Include="$(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png" Condition="'$(Config)'=='Debug'">
<RemoteDir>pmserverApk\res\drawable-normal\</RemoteDir>
<RemoteName>splash_image.png</RemoteName>
<DeployClass>Android_SplashImage470</DeployClass>
<Operation>1</Operation>
<LocalCommand/>
<RemoteCommand/>
<Overwrite>True</Overwrite>
</DeployFile>
</ItemGroup>
<ItemGroup Condition="'$(Platform)'=='iOSDevice64'"/>
<ItemGroup Condition="'$(Platform)'=='iOSSimARM64'"/>
</Project>
+34
View File
@@ -0,0 +1,34 @@
program pmserverApk;
uses
System.StartUpCopy,
FMX.Forms,
mainApk in 'mainApk.pas' {Form8},
PMA.Crypto in 'android-client\PMA.Crypto.pas',
PMA.TestVector in 'android-client\PMA.TestVector.pas',
PMA.SyncClient in 'android-client\PMA.SyncClient.pas',
PMA.Store in 'android-client\PMA.Store.pas',
PMA.Biometric in 'android-client\PMA.Biometric.pas',
PMA.Vault in 'android-client\PMA.Vault.pas',
PMA.Totp in 'android-client\PMA.Totp.pas',
PMA.ScreenGuard in 'android-client\PMA.ScreenGuard.pas',
PMA.Clipboard in 'android-client\PMA.Clipboard.pas',
PMA.Theme in 'android-client\PMA.Theme.pas',
PMA.WebUI in 'android-client\PMA.WebUI.pas',
PMA.VaultPatch in 'android-client\PMA.VaultPatch.pas',
PMA.SaveFile in 'android-client\PMA.SaveFile.pas',
PMA.Favicon in 'android-client\PMA.Favicon.pas';
{$R *.res}
begin
// NI SKIA NI STYLE. Les deux servaient les controles FMX : Skia pour mettre
// en forme l'arabe dans les libelles - le moteur d'origine ne lie pas les
// lettres et ne reordonne pas le bidirectionnel - et le style pour que les
// champs et les listes ne s'affichent pas en clair. L'interface est passee au
// moteur du systeme, qui fait les deux lui-meme ; il ne reste plus un seul
// controle FMX a habiller. Au passage, libsk4d.so ne pese plus dans l'APK.
Application.Initialize;
Application.CreateForm(TForm8, Form8);
Application.Run;
end.
+1324
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
# Endpoint de synchronisation PHP (lecture seule)
Alternative au WebDAV pour distribuer l'instantané chiffré du coffre. Le serveur
**ne déchiffre rien** : il sert des octets opaques.
## Installation
1. Copier `config.example.php` en `config.php`, y mettre un jeton :
```bash
php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
```
2. Créer le dossier de données **hors racine web** et y déposer l'instantané
produit par l'application Windows (`vault-sync.json`).
3. Servir `sync.php` en HTTPS. Sans TLS, l'endpoint refuse la requête : le jeton
circule dans un en-tête à chaque appel.
## Utilisation
```
GET /sync.php
Authorization: Bearer <jeton>
```
- **200** — le conteneur chiffré, avec un `ETag` fort.
- **304** — si le client renvoie `If-None-Match` avec l'ETag qu'il détient.
- **404** — pas encore d'instantané, *ou* jeton invalide. Les deux répondent
pareil : un appelant non authentifié n'apprend pas si quelque chose existe ici.
- **405** — toute autre méthode. L'écriture viendra avec le client bidirectionnel.
## Ce que le serveur voit
Le conteneur est chiffré côté client (PBKDF2-SHA256 600 k + AES-GCM) avec le
**mot de passe de sync**, qui n'est jamais transmis. Un serveur compromis livre
donc du chiffré, plus ces métadonnées : la taille du fichier, sa date de
modification, et la fréquence des synchronisations.
Le jeton authentifie l'**appareil**, pas le coffre. Le voler donne accès au
chiffré, pas au contenu.
## Étape suivante
L'écriture demandera `PUT` avec `If-Match: <etag>` — c'est ce qui empêche deux
appareils de s'écraser mutuellement. Le client Windows sait déjà le faire : il
capture l'ETag à la lecture et rejoue le cycle complet sur un **412**
(`runSyncNow`, borné à 3 tentatives).
+17
View File
@@ -0,0 +1,17 @@
<?php
/**
* Copy to config.php and fill in. config.php is gitignored — it holds a secret.
*/
declare(strict_types=1);
// Bearer token the clients send. Generate one, never reuse a password:
// php -r "echo bin2hex(random_bytes(32)), PHP_EOL;"
// It authenticates the DEVICE, not the vault: it grants access to the
// ciphertext only. The sync password (which decrypts it) never leaves the
// clients and is never known to this server.
const SYNC_TOKEN = 'PUT-A-64-HEX-TOKEN-HERE';
// Where the snapshot lives. Keep it OUTSIDE the web root: served through this
// script only, never fetchable as a static file.
const SYNC_FILE = __DIR__ . '/../vault-data/vault-sync.json';
+119
View File
@@ -0,0 +1,119 @@
<?php
/**
* Read-only sync endpoint for the vault snapshot.
*
* The snapshot is an OPAQUE encrypted container (pm-encrypted-export-v1):
* PBKDF2-SHA256 600k + AES-GCM, produced and read by the clients. This script
* never decrypts anything and never needs to — it stores and serves bytes.
* A compromised server yields ciphertext plus the metadata below, nothing more.
*
* Deliberately read-only for now: GET only. The write path (PUT with If-Match)
* comes with the two-way client and is refused explicitly rather than silently
* ignored, so a client that tries it fails loudly instead of believing it wrote.
*/
declare(strict_types=1);
require __DIR__ . '/config.php'; // defines SYNC_TOKEN and SYNC_FILE
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
// The token travels in a header on every request; without TLS it is readable by
// anyone on the path, and so is the ciphertext. Refuse rather than leak.
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|| in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
if (!$https) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'HTTPS required']);
exit;
}
header('Content-Type: application/json');
// This endpoint is for native clients, never a browser page: no CORS, no cache.
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/**
* Bearer token, compared in constant time so a wrong guess leaks no timing.
*
* Three sources on purpose: Apache commonly strips Authorization before PHP
* sees it (it is only forwarded with CGIPassAuth on, or through a rewrite), so
* $_SERVER alone answers empty on a stock XAMPP and every request looks
* unauthenticated. getallheaders() reads what the client actually sent.
*/
function client_token(): string {
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if ($h === '' && function_exists('getallheaders')) {
foreach (getallheaders() as $name => $value) {
if (strcasecmp($name, 'Authorization') === 0) { $h = $value; break; }
}
}
if (stripos($h, 'Bearer ') === 0) return trim(substr($h, 7));
return '';
}
if (!hash_equals(SYNC_TOKEN, client_token())) {
// 404, not 401: an unauthenticated caller learns nothing about what lives
// here — same reasoning as the desktop server's loopback lockdown.
http_response_code(404);
echo json_encode(['error' => 'Not found']);
exit;
}
// ---------------------------------------------------------------------------
// Routing
// ---------------------------------------------------------------------------
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if ($method === 'HEAD' || $method === 'GET') {
if (!is_file(SYNC_FILE)) {
// 404 = "no snapshot yet". The desktop client already treats this as
// "first sync, remote is empty" rather than an error.
http_response_code(404);
echo json_encode(['error' => 'No snapshot yet']);
exit;
}
$blob = file_get_contents(SYNC_FILE);
if ($blob === false) {
http_response_code(500);
echo json_encode(['error' => 'Read failed']);
exit;
}
// Strong ETag over the bytes. The write path will require If-Match against
// it, which is what stops two devices from clobbering each other.
$etag = '"' . hash('sha256', $blob) . '"';
header('ETag: ' . $etag);
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', (int)filemtime(SYNC_FILE)) . ' GMT');
// Save the round trip when the client already holds this version.
// Compared on the bare hash: a validator that loses its quotes or arrives
// as a weak tag (W/"…") would otherwise never match, and every request
// would silently download the whole snapshot again — costly on mobile data
// and invisible in the logs, since a 200 looks perfectly normal.
$sent = trim($_SERVER['HTTP_IF_NONE_MATCH'] ?? '');
if ($sent !== '' && trim($sent, 'W/" ') === trim($etag, '" ')) {
http_response_code(304);
exit;
}
header('Content-Length: ' . strlen($blob));
if ($method === 'HEAD') exit;
echo $blob;
exit;
}
// Everything else — including PUT — is not implemented yet.
http_response_code(405);
header('Allow: GET, HEAD');
echo json_encode(['error' => 'Read-only endpoint']);
+130
View File
@@ -0,0 +1,130 @@
# Fabrique des licences signees. A LANCER CHEZ TOI, JAMAIS LIVRE.
#
# .\tools\licence.ps1 -Init cree la paire de cles (une seule fois)
# et imprime la constante Delphi a coller
# .\tools\licence.ps1 -For "a@b.com" -Name "Ami" fabrique une licence
#
# POURQUOI ECDSA P-256 ET PAS AUTRE CHOSE : .NET sur Windows EST CNG. La
# signature que produit ce script (r||s brut, 64 octets) est exactement celle
# qu'attend BCryptVerifySignature cote Delphi, et les coordonnees exportees ici
# se rangent telles quelles dans un BCRYPT_ECCPUBLIC_BLOB. Aucun ASN.1 a lire
# d'un cote ni de l'autre - c'est ce qui rend la verification courte.
param(
[switch]$Init,
[string]$For,
[string]$Name = '',
[string]$Machine,
# HORS DU DEPOT PAR CONSTRUCTION. Elle a d'abord vecu dans tools\, ou elle
# n'attendait qu'un menage pour disparaitre - ce qui est arrive, et une cle
# privee perdue interdit toute nouvelle licence a jamais. Le .gitignore la
# protegeait du depot, rien ne la protegeait de son proprietaire.
[string]$KeyFile = "$env:USERPROFILE\Documents\pmserver-licence-private.key"
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Security
function ToB64Url([byte[]]$b) {
[Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}
if ($Init) {
if (Test-Path $KeyFile) {
throw "$KeyFile existe deja. L'ecraser invaliderait TOUTES les licences deja emises. Deplace-le d'abord si c'est voulu."
}
$ecdsa = New-Object System.Security.Cryptography.ECDsaCng 256
$p = $ecdsa.ExportParameters($true)
# La cle privee : D, plus le point public pour pouvoir re-signer sans le
# recalculer. 96 octets, en hexa pour rester lisible dans un gestionnaire
# de mots de passe - c'est la ou elle doit finir, pas dans ce dossier.
$priv = [byte[]]($p.D + $p.Q.X + $p.Q.Y)
[System.IO.File]::WriteAllText($KeyFile, ([BitConverter]::ToString($priv) -replace '-', ''))
# BCRYPT_ECCPUBLIC_BLOB : magic ECS1 + longueur + X + Y.
$blob = [byte[]]([BitConverter]::GetBytes([uint32]0x31534345) +
[BitConverter]::GetBytes([uint32]32) + $p.Q.X + $p.Q.Y)
''
'Cle privee ecrite dans : ' + $KeyFile
'METS-LA A L ABRI ET SUPPRIME-LA D ICI. Qui la detient fabrique des licences.'
''
'--- a coller dans PM.Licence.pas ---'
''
'const'
' LicencePublicKeyBlob: array[0..71] of Byte = ('
for ($i = 0; $i -lt $blob.Length; $i += 12) {
$row = ($blob[$i..([Math]::Min($i + 11, $blob.Length - 1))] |
ForEach-Object { '$' + $_.ToString('X2') }) -join ', '
if ($i + 12 -lt $blob.Length) { ' ' + $row + ',' } else { ' ' + $row }
}
' );'
''
return
}
if (-not $For) { throw 'Donne -For <email> (ou -Init pour creer la paire de cles).' }
if (-not (Test-Path $KeyFile)) { throw "Pas de cle privee dans $KeyFile. Lance -Init, ou remets la cle mise a l'abri." }
$hex = (Get-Content $KeyFile -Raw).Trim()
$priv = [byte[]]::new($hex.Length / 2)
for ($i = 0; $i -lt $priv.Length; $i++) { $priv[$i] = [Convert]::ToByte($hex.Substring($i * 2, 2), 16) }
$p = New-Object System.Security.Cryptography.ECParameters
$p.Curve = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP256')
$p.D = $priv[0..31]
$q = New-Object System.Security.Cryptography.ECPoint
$q.X = $priv[32..63]
$q.Y = $priv[64..95]
$p.Q = $q
$ecdsa = [System.Security.Cryptography.ECDsa]::Create($p)
# LE LIAGE AU POSTE EST OBLIGATOIRE, ET IL N'Y A PLUS DE DEROGATION.
#
# Les licences flottantes (sans champ m) existaient ; l'exe les refuse
# desormais, ce qui etait la seule facon d'annuler celle qui etait sortie -
# un schema hors ligne n'a pas de revocation. En emettre une aujourd'hui
# produirait une cle que l'application rejettera : autant ne pas pouvoir.
#
# L'empreinte est celle qu'affiche l'ecran d'activation sur le poste du
# client : il faut donc qu'il ait installe l'application AVANT qu'on emette
# sa licence. C'est le prix du liage.
if (-not $Machine) {
throw @'
Il manque -Machine <empreinte>.
Fais-toi envoyer l'empreinte affichee sur l'ecran d'activation du poste
(format XXXX-XXXX-XXXX-XXXX), puis :
.\licence.ps1 -For "a@b.com" -Name "Nom" -Machine "XXXX-XXXX-XXXX-XXXX"
Une licence non liee n'est plus emettable : l'application la refuserait.
'@
}
# Charge utile courte : elle voyage dans la cle que l'utilisateur recopie.
# Pas d'expiration - une date qu'on fait respecter avec l'horloge de la
# machine du client ne prouve rien. A ajouter le jour ou on vend un abonnement.
$payload = [ordered]@{ n = $Name; e = $For; d = (Get-Date).ToString('yyyy-MM-dd') }
# Les tirets ne sont qu'une aide a la recopie ; Delphi compare le condense
# nu, en minuscules.
$payload.m = ($Machine -replace '[^0-9A-Fa-f]', '').ToLower()
if ($payload.m.Length -ne 16) {
throw "Empreinte attendue : 16 caracteres hexadecimaux (recu $($payload.m.Length))."
}
$json = ($payload | ConvertTo-Json -Compress)
$bytes = [Text.Encoding]::UTF8.GetBytes($json)
$sig = $ecdsa.SignData($bytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
$licence = (ToB64Url $bytes) + '.' + (ToB64Url $sig)
''
'Licence pour ' + $For + ' — liee au poste ' + $Machine
''
$licence
''
'Verification locale : ' + $ecdsa.VerifyData($bytes, $sig, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
''