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>
This commit is contained in:
r-zakarya
2026-09-05 14:58:19 +01:00
parent 960d2b388a
commit cd6e70449d
5 changed files with 156 additions and 1 deletions
+30
View File
@@ -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
+1
View File
@@ -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;
+33 -1
View File
@@ -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
+23
View File
@@ -85,6 +85,29 @@
<!-- ============================================================ -->
<!-- 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>
</section>
<section id="authScreen" class="auth-screen">
<div class="auth-card">
<div class="auth-brand">
+69
View File
@@ -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;