Commit Graph

64 Commits

Author SHA1 Message Date
r-zakarya 1fa5113c02 feat(licence): deuxieme couche, l'API refuse sans licence valable
L'ecran d'activation ne consultait qu'un branchement : le rapiecer
ouvrait tout, l'API ne demandant rien. Chaque route de PM.Router passe
maintenant par PM.LicenceGate.ApiLicenceOk avant son handler.

Aucune fonction de decision partagee avec PM.Licence — sinon un seul
rapiecage ouvrirait les deux couches. La verification est refaite de
bout en bout, appels CNG redeclares compris ; seules la cle publique et
le nom de la pref sont partages, parce que ce sont des donnees.

Branchee dans DispatchRequest : les fichiers de l'ecran d'activation
passent par TryServeEmbedded, non garde, sinon l'ecran ne se chargerait
plus. Un oui est retenu pour le processus, un non jamais.

Le risque etait qu'elle refuse un client licencie. Auto-test sur un
vecteur lie a une empreinte fixe, sens accepte en premier ; et accord
verifie en conditions reelles sur ce poste : IsLicensed = TRUE,
ApiLicenceOk = TRUE.

Documente aussi le piege du jour, sans lien avec ce code : des .dcu TMS
x64 deposes a cote des sources cassaient le build Win32, et la sortie
redirigee vers E: faute de place sur Z:.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:55:33 +01:00
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 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 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 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 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 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 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 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 e919a79522 docs(claude): document the multiline Notes field and the custom-field row 2026-08-01 14:00:45 +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 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 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 01426a48b1 refactor(db): drop cleartext template refs, fix ghost columns + fresh-DB schema
Migration verified (0 unmigrated cleartext templates across both accounts)
before contracting:
- GET/POST/PUT/bulk no longer read or write the cleartext template column
  (only template_enc/iv); the PUT partial-update gate stays keyed on the
  'template' JSON key presence.
- Removed AddColumnIfMissing for template AND tags/title — those two had
  silently re-added the dropped columns as empty ghosts at every start.
- CREATE TABLE: removed site/username NOT NULL cleartext columns — a FRESH
  database rejected the very first INSERT (which no longer ships them).

User can now DROP COLUMN template (and re-drop the ghost tags/title).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:52:26 +01:00
r-zakarya d583a3f7d3 refactor(js): extract attachments, autofill, unlock modules from app.js (3.1)
app.js 9363 -> 7962 lines. Three new classic-script modules:
- app.attachments.js (250): blob crypto + upload/download UI, pure
  declarations, loads before app.js
- app.autofill.js (276): Win32 combos, title->entry matching, picker,
  pure declarations, loads before app.js
- app.unlock.js (907): Quick Unlock + PIN + recovery code grouped (same
  "enter without master pw" theme); assigns Bridge.onPinResult /
  onQuickUnlockResult at top level so it loads AFTER app.js, like app.sync.js

Audit viewer stays in app.js (only 65 lines, not worth a file). Clipboard
bridge helpers stay too (were interleaved in the quick-unlock section but
unrelated). Registered in BuildAssets whitelist + index.html + APP_PARTS.
Verified in-app: quick unlock cold-start, attachment upload/download.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:28:19 +01:00
r-zakarya 83a3dfdfc2 docs: note assets.rc/.inc alongside assets.res as gitignored build output
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:35:54 +01:00
r-zakarya a42e4b205d feat(entries): encrypt template at rest, guided tour, import fixes, cleanup
Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:37 +01:00
r-zakarya be84f0feb5 docs: CLAUDE.md — cleartext meta columns dropped post-migration 2026-07-10 11:14:03 +01:00
r-zakarya 6556ce8dea feat(crypto): encrypt site/title/tags at rest too (CODE_AUDIT §1.3)
Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.

Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
  cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
  cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.

Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
  AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
  rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
  client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
  frontend never sends ?search=).

Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.

username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:25:13 +01:00
r-zakarya 69fb2b10dd feat(crypto): encrypt username at rest (CODE_AUDIT §1.3)
username is no longer stored cleartext. New columns username_enc/username_iv
(AES-GCM under the vault key, same as encrypted_password). Search/sort/render
stay client-side, so the field is decrypted at loadEntries into e.username in
memory — everything downstream is unchanged. Full-strength random-IV AES-GCM
(no searchable/deterministic encryption) precisely because search is
client-side.

Server (PM.Handler.Entries / .Auth / PM.Database):
- Schema: vault_entries.username_enc, username_iv.
- GET returns them; POST/PUT/bulk-import read + persist them; master-pw
  rotation re-encrypts them under the new key (UPDATE + loop).
- ?q= server search drops `username LIKE` (ciphertext won't match; frontend
  searches client-side anyway).

Client (app.js / app.import.js):
- loadEntries/loadTrash decrypt username_enc → e.username (fallback to
  cleartext for un-migrated rows).
- withEncryptedUsername(obj): write choke point — encrypts obj.username into
  username_enc/username_iv and blanks the cleartext. Wraps every POST/PUT
  body: saveEntry, soSave, duplicateEntry, moveEntryToFolder, addTagToEntry,
  batchMove/AddTag, encryptImportEntry (import + sync-apply).
- doChangeMasterPassword re-encrypts username under the new key.
- migrateUsernamesAtRest(): one-time sweep at enterApp, PUT-re-ships rows that
  still carry cleartext username so the DB gets scrubbed (bumps updated_at
  once; plaintext unchanged so devices converge).

site/title/tags stay cleartext (same pattern later — see memory note). +1
merge test (username encrypted on import). 65/65.

NOT compiled/tested at runtime (Delphi) — large multi-handler change; rebuild
BuildAssets + PMServer and test create/edit/rotate/import/sync + verify the
DB shows no cleartext username.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:04:48 +01:00
r-zakarya c58424d58c feat(sync): include avatar in sync snapshot + auto-backup (multi-device)
The profile picture (users.avatar_b64, cosmetic/unencrypted) travelled only
in the manual export. Now it's also in buildSyncSnapshot and the auto-backup
container, so a new device / a restore picks it up.

- Restore is ADDITIVE (mirrors the import path): applyRemoteSnapshot adopts
  remote.avatar_b64 only when the local device has no avatar — never clobbers
  a locally-set picture. No per-avatar timestamp to arbitrate, so changing an
  existing avatar doesn't propagate (cosmetic, accepted).
- +2 merge tests (adopt-when-empty, don't-clobber-when-set). 65/65 green.
- Server /avatar endpoint unchanged (already accepts {avatar_b64}).

Closes the avatar item of CODE_AUDIT §4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:06:31 +01:00
r-zakarya 9e424efaf4 refactor(js): extract quick-search overlay cluster from app.js (§3.1)
Eighth slice. Quick search wasn't contiguous — its functions sat on both
sides of the cheatsheet and password-history modals (lines 725-1104). Rather
than a fiddly non-contiguous cut, the whole overlay cluster is extracted as
one byte-identical block: js/app.overlays.js (quick search + cheatsheet +
password history). Pure declarations, no top-level side effects → loads
before app.js; all state/api/Bridge/render/decryptPwd refs resolve via shared
global scope at call time.

- Byte-for-byte identical; syntax OK on all nine app parts; 62/62 tests green.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.

app.js: 11936 → 9170 lines (8 modules extracted, ~2770 lines). Load order:
argon2 → crypto → totp → favicon → import → backup → health → overlays →
app → sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:43:55 +01:00
r-zakarya b44b05118e refactor(js): extract vault-health module from app.js monofile (§3.1)
Seventh slice of the app.js split. Moves the vault-health dashboard
(computeHealthCache, healthScoreBand, renderHealthDashboard/Section,
openEntryForFix, entryAgeDays + scoring consts) to js/app.health.js. Pure
declarations, no top-level side effects → loads before app.js. Uses
computeStrength/decryptPwd/state/api via shared global scope at call time.

- Byte-for-byte identical extraction; syntax OK on all eight app parts.
- auditCache/auditFilter sit in this var block but drive the separate
  Audit-log viewer in app.js — they ride along and resolve cross-file via
  shared scope (documented).
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- 62/62 tests green.

app.js: 11936 → 9545 lines (7 modules extracted).

NOTE: quick search is NOT contiguous (interleaved with cheatsheet +
history-modal code, lines 886-1063), so a clean byte-identical extraction
isn't trivial — deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:07:42 +01:00
r-zakarya 8e0e7fd330 feat(quicksearch): remap fill modes — left/Enter=password, right/Shift=full
Per user request, swap the quick-search click/key mapping so the common case
(fill just the password) is the plain left-click / Enter:

  left click  / Enter        → password only   (was: full user+Tab+pwd)
  right click / Shift+Enter   → full user+pwd   (was: username only)
  Ctrl+click  / Ctrl+Enter    → username only   (was: password only)

Keyboard mirrors the mouse. Copy-mode (tray/palette, no HWND target) shares
the same `mode`, so it shifts too: click/Enter copies password, Ctrl+click/
Ctrl+Enter copies username (right-click's 'full' has no copy meaning → pwd).
Updated the dynamic hint, the static index.html hint, and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:24:57 +01:00
r-zakarya a7ad81c708 refactor(js): extract favicon module + add faviconHost tests (§3.1)
Sixth slice of the app.js split. Moves the favicon fetch/cache section
(faviconHost, saveEntryIcon, ensureEntryFavicon, backfillFavicons,
clearAllFavicons) to js/app.favicon.js. Pure declarations, no top-level
side effects → loads before app.js.

- Code moved byte-for-byte; no duplicate const; syntax OK on all 7 app parts.
- NEW: js/tests/favicon.test.js — 7 tests for faviconHost, the pure
  URL→validated-hostname function that decides which domain is sent to the
  DuckDuckGo proxy (a bug there leaks the wrong host). Covers scheme/www/
  path/port stripping, non-hostname rejection, malformed dotting, unsafe
  chars, and the 253-char DNS cap.
- Fixed an inaccurate source comment surfaced by the tests: it claimed raw
  IPs "stay valid", but the TLD rule /\.[a-z]{2,}$/ rejects a numeric final
  label, so IPs get no favicon lookup (fine). Test pins the real behaviour.
- Suite: 55 → 62 tests, all green. Assets regenerated (8 ordered JS files).

app.js: 11936 → 9790 lines (6 modules extracted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:58:16 +01:00
r-zakarya 4fd768d4cf refactor(js): extract auto-backup module from app.js monofile (§3.1)
Fifth slice of the app.js split. Moves the scheduled encrypted-backup
feature (config, retention, runAutoBackupNow/runAutoBackupIfDue) to
js/app.backup.js. Pure declarations + two consts, no top-level side effects
→ loads before app.js; uses encryptExportPayload (app.import.js), Bridge,
api, state via shared global scope at call time.

- Byte-for-byte identical extraction; no duplicate const; no top-level
  backup reference left in app.js; syntax OK on all six app parts.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
  regenerated (manifest embeds all 7 ordered JS files).
- 55/55 tests green.

app.js: 11936 → 9900 lines — now under 10k. Five modules extracted
(~2000 lines): argon2 → crypto → totp → import → backup → app → sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:46:01 +01:00
r-zakarya 5fc07aed7a refactor(js): extract TOTP module + add RFC 6238 tests (§3.1)
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP,
parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to
js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js
(uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js
via shared global scope.

- Byte-for-byte identical extraction; no duplicate const; syntax OK on all
  five app parts.
- NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B
  reference vectors (generateTOTP reads Date.now(), so each case stubs the
  sandbox clock to the vector's fixed time), base32 decode edge cases, and
  parseOtpAuthUri. Extraction AND new coverage in one slice.
- Suite: 42 → 55 tests, all green.
- Assets regenerated (manifest now embeds all 6 ordered JS files:
  argon2 → crypto → totp → import → app → sync); also fixes the previous
  import commit's not-yet-rebuilt manifest.
- Delphi build artifacts (*.vrc, *.$manifest) gitignored.

app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:35:29 +01:00
r-zakarya 97a19836a0 refactor(js): extract import/export module from app.js monofile (§3.1)
Third slice of the app.js split. Moves the encrypted export container,
CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV,
parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to
js/app.import.js. encryptImportEntry moves here too (also called by
app.sync.js — resolved via shared global scope at call time).

- Byte-for-byte identical to the extracted block; no duplicate const;
  no top-level import ref left in app.js.
- Load order: BEFORE app.js (pure declarations, no top-level side effects),
  alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync.
- index.html + BuildAssets whitelist + harness APP_PARTS updated.
- Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from
  the extracted file and stay green (42/42).

app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700
lines moved into 3 modules).

NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run
BuildAssets before the next Delphi build to embed js/app.import.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:24:11 +01:00
r-zakarya 2d309f8988 refactor(js): extract sync module from app.js monofile (§3.1)
Second slice of the app.js split (after crypto). Moves the WebDAV sync
section to js/app.sync.js: transport (_webdavCall), buildSyncSnapshot,
applyRemoteSnapshot (merge + tombstone arbitration), runSyncNow, and the
sync settings UI.

- Byte-for-byte identical to the extracted block (verified before removal);
  no duplicate const; no top-level sync reference left in app.js.
- Load order: AFTER app.js (unlike crypto, which loads before) because this
  module has a top-level side effect — `Bridge.onWebdavResult = …` — that
  needs Bridge/state/api already declared. Rule documented in CLAUDE.md.
- index.html + BuildAssets whitelist + harness APP_PARTS updated; assets
  rebuilt to embed the new file.
- Safety net: the existing merge tests exercise applyRemoteSnapshot /
  buildSyncSnapshot from the extracted file and stay green (42/42).

app.js: 11936 → 11256 lines (crypto + sync now separate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 16:09:02 +01:00
r-zakarya 4ffbd63893 fix(sync): bump updated_at on set-icon + folder-delete reassignment
Both UPDATEs mutated a synced column without touching updated_at, so the
change rode in the sync snapshot but other devices skipped it (last-write-
wins saw "not newer"). Now both SET updated_at = datetime('now') (UTC).

- POST /entries/{id}/icon (PM.Handler.Entries)
- folder delete → entries reassigned to 'All' (PM.Handler.Folders)

accessed_at stays exempt (read timestamp, not synced); bulk "clear all
icons" stays exempt (device-local favicon cache purge). Invariant documented
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:55:23 +01:00
r-zakarya ca8081987d refactor(js): extract crypto module from app.js monofile (§3.1 start)
First slice of the app.js split. Approach: ordered classic-script files
loaded via separate <script> tags (argon2.js → app.crypto.js → app.js),
NOT ES modules / a bundler. Classic scripts share one global lexical
environment, so consts/functions cross-reference across files exactly as
in the monofile — zero call-site rewrites, near-zero risk. Chosen over the
audit's esbuild/ES-module suggestion because the code is written entirely
in global scope (functions call each other by bare name everywhere).

- js/app.crypto.js: KDF (PBKDF2 + Argon2id), verifier, AES-GCM encrypt/
  decrypt, key persist/restore. Verified byte-for-byte identical to the
  original block before removal; no duplicate const across the two scripts.
- index.html + BuildAssets whitelist + test harness updated for the load
  order. Harness CONCATENATES app.crypto.js + app.js (node:vm doesn't share
  top-level const across separate runInContext calls the way browsers share
  it across <script> tags); argon2.js stays a separate IIFE.
- Runtime-validated: rebuilt exe unlocks via quick-unlock and loads/decrypts
  entries — the extracted crypto (restoreCryptoKey, verifierFromKeyHex,
  decryptPwd) works from the separate file. 42/42 tests green.
- Docs: CLAUDE.md "Découpage frontend" (pattern + rules), file map, tests
  README, CODE_AUDIT §3.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:44:06 +01:00
r-zakarya 5e88ad33d1 feat(crypto): adopt Argon2id (argon2id-v2) on register + master-pw change
Phase 2 of CODE_AUDIT §1.2 — live adoption of the Argon2id foundation.
Verified at runtime: a rotated account shows hash_algo=argon2id-v2 with
argon2_m=19456,t=2,p=1 in vault.db.

Server (never runs Argon2 — zero-knowledge, only stores/echoes params):
- DB: users.argon2_m/t/p columns (default 0 = PBKDF2).
- PM.Handler.Auth: HASH_ALGO_ARGON2 + param bounds, ReadArgon2Params /
  AppendArgon2Params helpers. /register and /change-master-password accept
  hashAlgo='argon2id-v2' + argon2:{m,t,p} and persist them; /login/challenge
  echoes them. Verify path (VerifierToStoredHash/CheckVerifier) is
  KDF-agnostic — the 64-hex verifier is SHA256-wrapped as for any -v2 scheme.

Client (app.js):
- state.argon2Params, cached from the challenge and persisted to
  sessionStorage + the quick-unlock / PIN cold-start blobs (so a cold-started
  session can still derive-from-password for reauth/rotation).
- Register + master-pw rotation derive with argon2id-v2 + ARGON2_DEFAULT_PARAMS
  (OWASP m=19MiB,t=2,p=1) and send the params. Rotation re-encrypts the whole
  vault under the new Argon2 key (natural migration point). Existing accounts
  stay PBKDF2 until they rotate.
- Params threaded through every derive-from-password site (login, reauth,
  recovery setup, change-pw current verifier). Cold-start verifier-from-raw-key
  paths need no params (isDecoupledVerifierAlgo handles the -v2 wrap).

Tests: +2 param-contract tests (register<->login determinism, param
sensitivity). 42/42. Assets rebuilt to embed js/argon2.js.

Docs: CLAUDE.md auth-hash section rewritten (4 markers); CODE_AUDIT §1.2 +
table + plan marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:52:11 +01:00
r-zakarya d9397881dc test: add frontend unit suite + fix mixed local/UTC timestamps
Two CODE_AUDIT items in one session.

§3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps):
- harness.js loads app.js (monofile, no exports) into a node:vm with browser
  globals stubbed, surfacing internals via an export epilogue.
- crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node
  pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip,
  IV uniqueness, AEAD tamper/wrong-key.
- csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping.
- merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete,
  resurrection arbitration (both NaN branches), local-tombstone veto,
  additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry
  run for real.
- Wired as a build gate in BuildAssets.ps1 (after node --check, bypass
  PM_SKIP_TESTS=1).

§2.2 — Unify timestamps on UTC:
- Entry created_at/updated_at were written via Delphi FormatDateTime(Now)
  = LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC.
  The tombstone-resurrection arbitration compared the two zones, skewing by
  the machine's UTC offset even single-device.
- Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment
  write site (Entries create/update/bulk, Attachments POST echo).
- No JS change needed: arbitration now compares same-zone values.
- Existing rows self-heal on next edit (no destructive migration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:17:33 +01:00