diff --git a/CLAUDE.md b/CLAUDE.md index 2db8682..d3dd7e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,6 +286,36 @@ tools\licence.ps1 -For "a@b.com" -Name "Ami" # une licence `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. + +**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 diff --git a/css/style.css b/css/style.css index cf46c4d..00a8ba5 100644 --- a/css/style.css +++ b/css/style.css @@ -236,6 +236,7 @@ input[type="range"]::-webkit-slider-thumb { radial-gradient(ellipse at bottom right, rgba(167,139,250,0.08), transparent 50%), var(--bg); } +#licenceScreen { z-index: 300; } .auth-card { width: 100%; max-width: 400px; padding: 32px; diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index cc19e67..1cc0335 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -34,7 +34,7 @@ uses {$IFDEF USE_EDGE_BROWSER} FMX.TMSFNCEdgeWebBrowser, {$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, FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check const @@ -1330,6 +1330,38 @@ begin PM.UserPrefs.SetPref(LKey, GetParam('value')); 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. + 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; + var LHolder := LLicInfo.Holder; + if LHolder = '' then LHolder := LLicInfo.Email; + LHolder := StringReplace(LHolder, '\', '\\', [rfReplaceAll]); + LHolder := StringReplace(LHolder, '"', '\"', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onLicenceResult)' + + 'Bridge.onLicenceResult(' + BoolToStr(LLicOk, True).ToLower + + ',"' + LHolder + '")'); + end + // ---- Start with Windows (HKCU Run registry) -------------------------- else if ACmd = 'autostart/get' then begin diff --git a/index.html b/index.html index 01367b8..51a0cde 100644 --- a/index.html +++ b/index.html @@ -85,6 +85,29 @@ + + + + +
diff --git a/js/app.js b/js/app.js index d4e6c61..9f43bc0 100644 --- a/js/app.js +++ b/js/app.js @@ -14,6 +14,7 @@ const API = (location.pathname.indexOf('/password-manager/') === 0) // Active only when running inside the Delphi-hosted WebView2 (API === ''). // Falls back to navigator.clipboard for the standalone PHP frontend. const prefResolvers = {}; +let licenceResolver = null; const fileSaveResolvers = {}; const fileChunkResolvers = {}; // Stream a base64 string to Delphi in URL-sized pieces via cmd://file/chunk, @@ -293,6 +294,33 @@ const Bridge = (() => { cmd('cmd://prefs/set?key=' + encodeURIComponent(key) + '&value=' + encodeURIComponent(value || '')); }, + // Licence check / activation, verified in Delphi against the + // publisher's public key (PM.Licence). Hors du navigateur hôte il n'y + // a pas de licence à vérifier — le harnais de test et le dev en + // navigateur passent. + // ponytail: un seul résolveur, pas une table comme prefResolvers — les + // deux appels ne se croisent jamais (statut au démarrage, activation + // sur clic). Passer à une table le jour où ils se croiseraient. + licence(action, key) { + if (!active) return Promise.resolve({ ok: true, holder: '' }); + return new Promise(resolve => { + licenceResolver = resolve; + cmd('cmd://licence/' + action + + (action === 'activate' ? '?key=' + encodeURIComponent(key || '') : '')); + setTimeout(() => { + if (licenceResolver === resolve) { + licenceResolver = null; + resolve({ ok: false, holder: '' }); + } + }, 3000); + }); + }, + onLicenceResult(ok, holder) { + const r = licenceResolver; + if (!r) return; + licenceResolver = null; + r({ ok: !!ok, holder: holder || '' }); + }, onPrefResult(key, value) { const r = prefResolvers[key]; if (r) { @@ -6908,6 +6936,43 @@ function resetAutoLock() { }, { passive: true }) ); +// La porte d'entrée : sans licence valable, rien d'autre ne se peint. +// Ne rend la main QUE sur une licence acceptée — il n'y a pas de bouton +// "plus tard", c'est tout l'objet de l'écran. +async function ensureLicence() { + if ((await Bridge.licence('status')).ok) return; + // Un démarrage à froid lent peut manquer la fenêtre de réponse, et le + // silence se lit alors comme un refus : redemander une fois coûte moins + // qu'un écran d'activation présenté à quelqu'un qui a déjà payé. + if ((await Bridge.licence('status')).ok) return; + + const screen = $('#licenceScreen'); + const hint = $('#licenceHint'); + const input = $('#licenceKey'); + screen.classList.remove('is-hidden'); + input.focus(); + + await new Promise(resolve => { + $('#licenceForm').addEventListener('submit', async e => { + e.preventDefault(); + const key = input.value.trim(); + if (!key) return; + hint.textContent = 'Checking…'; + const res = await Bridge.licence('activate', key); + if (!res.ok) { + // Une clé refusée ne dit pas POURQUOI : signature invalide, + // recopie fautive et charge utile trafiquée se ressemblent, et + // détailler n'aiderait que celui qui essaie d'en fabriquer une. + hint.textContent = 'This licence key is not valid.'; + input.select(); + return; + } + screen.classList.add('is-hidden'); + resolve(); + }); + }); +} + async function showAuth() { $('#authScreen').classList.remove('is-hidden'); $('#appShell').classList.add('is-hidden'); @@ -7353,6 +7418,10 @@ async function init() { history.replaceState(null, '', location.pathname + location.hash); } + // Avant tout le reste : un écran d'authentification qui s'affiche puis se + // fait recouvrir donnerait l'impression que le coffre était accessible. + await ensureLicence(); + installCustomContextMenu(); document.addEventListener('keydown', e => { const mod = e.ctrlKey || e.metaKey;