diff --git a/CLAUDE.md b/CLAUDE.md
index b41fb01..23740e7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -459,6 +459,43 @@ 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.
+### Deuxieme couche : l'API refuse sans licence (`PM.LicenceGate`)
+
+La premiere couche (l'ecran d'activation) ne consultait qu'un branchement,
+`IsLicensed` ; le rapiecer ouvrait tout, l'API ne demandant rien. **Chaque route
+de `PM.Router` passe maintenant par `ApiLicenceOk` avant son handler** — sauter
+l'ecran ne donne plus acces aux donnees.
+
+- **Aucune fonction de decision partagee avec `PM.Licence`.** Si les deux
+ couches appelaient `IsLicensed`, un seul rapiecage les ouvrirait toutes les
+ deux. `PM.LicenceGate` refait sa propre verification de bout en bout —
+ decodage, SHA-256, appels CNG **redeclares sous d'autres noms**, lecture du
+ champ `m` — et ne prend a `PM.Licence` que des **donnees** : la cle publique
+ (`LicencePublicKey`) et le nom de la pref (`LicencePrefKey`).
+- **Branchee dans `DispatchRequest`, pas avant.** Seules les routes de l'API
+ atteignent ce point ; les fichiers de l'ecran d'activation passent par
+ `TryServeEmbedded`, non garde — sinon l'ecran qui permet de s'activer ne se
+ chargerait plus. **404**, pas 403, comme le reste du verrouillage.
+- Un **oui** est retenu pour le processus (le test est sur le chemin de chaque
+ requete) ; un **non** jamais, pour qu'une activation faite juste avant soit
+ vue a la requete suivante.
+- **Le prix de l'independance : deux implementations qui doivent dire la meme
+ chose.** Une divergence ferait passer l'ecran puis tomber chaque requete en
+ 404 — refuser quelqu'un qui a paye. `SelfTestLicenceGate` eprouve la 2e couche
+ seule sur un vecteur lie a une empreinte fixe (`0123456789abcdef`, paire
+ jetable dont la privee n'a jamais ete ecrite), **en commencant par le sens qui
+ accepte**. L'**accord** entre couches ne se teste pas en vecteur — la 1re
+ compare a l'empreinte reelle du poste — et se prouve en conditions reelles :
+ au 2026-09-16, sur ce poste avec la vraie licence, `IsLicensed` = TRUE et
+ `ApiLicenceOk` = TRUE.
+- **Ce que ca n'arrete pas** : quelqu'un qui trouve et rapiece les deux. Une
+ heure devient deux ; la nature d'un schema hors ligne ne change pas. Les
+ protections du commerce (VMProtect, Themida) iraient plus loin mais
+ declenchent des **faux positifs antivirus** — ruineux pour un gestionnaire de
+ mots de passe. Les outils libres ne s'appliquent pas : OLLVM exige un
+ compilateur LLVM (dcc32 Win32 n'en est pas un), Alcatraz est x64 seulement,
+ UPX se defait par `upx -d`.
+
**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 :
@@ -1375,6 +1412,25 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
fix port-fixe est possible (`port=N` dans `config.txt`) mais on perd
l'aléatoire qui complique le scan externe.
- **Modifier les `Enable*` props TMS en `OnInitialized`**, pas FormCreate.
+- **`F2048 Bad unit format … FMX.TMSFNC*.dcu … Found x64`** : le dossier des
+ sources `… mssoftware
egistered\TMS FNC Core\` est dans le chemin de
+ recherche, et une compilation y depose ses `.dcu` **a cote des `.pas`**. Une
+ compilation **Win64** (le 2026-09-07 a 11 h 45) en a reecrit 33 en x64, et le
+ build Win32 buttait dessus — **sans aucun rapport avec le code du projet** :
+ l'arbre propre echouait a l'identique. Correctif : les deplacer
+ (sauvegarde dans `… mssoftware\_backup-x64-dcu-2026-09-07`) ; le build Win32
+ retombe sur `Delphi12\Win32\Release\`. Le symptome reviendra a chaque
+ alternance Win32/Win64 sur ce poste.
+- **Z: est quasi plein (0,3 Go au 2026-09-16).** Le build est redirige vers
+ `E:\password manager\` **par surcharge MSBuild**, pas dans le `.dproj` — un
+ chemin propre a une machine n'a pas sa place dans un fichier versionne :
+ ```
+ msbuild PMServer.dproj /p:Config=Release /p:Platform=Win32
+ "/p:DCC_ExeOutput=E:\password manager" "/p:DCC_DcuOutput=E:\password manager\dcu"
+ ```
+ **F9 dans l'IDE sort toujours sur Z:.** Et l'exe de `E:\password manager\`
+ cree **son propre `vault.db`** a cote de lui, sans `config.txt` : ce n'est pas
+ le coffre de `delphi-backend\` ni celui de `E: pmanager\`.
## Conventions code (héritage sessions précédentes)
diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr
index ba85b28..577b33a 100644
--- a/delphi-backend/PMServer.dpr
+++ b/delphi-backend/PMServer.dpr
@@ -22,6 +22,7 @@ uses
PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.MachineId in 'Source\PM.MachineId.pas',
PM.Licence in 'Source\PM.Licence.pas',
+ PM.LicenceGate in 'Source\PM.LicenceGate.pas',
PM.AutoStart in 'Source\PM.AutoStart.pas',
PM.Favicon in 'Source\PM.Favicon.pas',
PM.ProcessLockdown in 'Source\PM.ProcessLockdown.pas',
diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj
index e6a790a..04c7add 100644
--- a/delphi-backend/PMServer.dproj
+++ b/delphi-backend/PMServer.dproj
@@ -229,6 +229,7 @@ $(PreBuildEvent)]]>
+
diff --git a/delphi-backend/Source/PM.Licence.pas b/delphi-backend/Source/PM.Licence.pas
index 049108c..c985898 100644
--- a/delphi-backend/Source/PM.Licence.pas
+++ b/delphi-backend/Source/PM.Licence.pas
@@ -60,6 +60,13 @@ function ThisMachineId: string;
// l'initialisation de l'unite, comme SelfTestCrypto.
procedure SelfTestLicence;
+// Exposes pour PM.LicenceGate, la deuxieme couche. Ce sont des DONNEES, pas
+// des decisions : elle partage la cle et le nom de la pref, et rien qui dise
+// oui ou non — sinon rapiecer une seule fonction ouvrirait les deux couches.
+const
+ LicencePrefKey = 'licenceKey';
+function LicencePublicKey: TBytes;
+
implementation
uses
@@ -67,8 +74,6 @@ uses
PM.UserPrefs, PM.MachineId;
const
- LicencePrefKey = 'licenceKey';
-
// ===== LA CLE PUBLIQUE DE L'EDITEUR ========================================
//
// BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32).
@@ -258,6 +263,11 @@ begin
Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen);
end;
+function LicencePublicKey: TBytes;
+begin
+ Result := ProductionKey;
+end;
+
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
begin
Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo) and
diff --git a/delphi-backend/Source/PM.LicenceGate.pas b/delphi-backend/Source/PM.LicenceGate.pas
new file mode 100644
index 0000000..35e9f0c
--- /dev/null
+++ b/delphi-backend/Source/PM.LicenceGate.pas
@@ -0,0 +1,220 @@
+unit PM.LicenceGate;
+
+{
+ Deuxieme couche de licence : l'API refuse de servir sans licence valable.
+
+ POURQUOI UNE DEUXIEME COUCHE, ET POURQUOI ICI. La premiere couche est l'ecran
+ d'activation, qui consulte PM.Licence.IsLicensed. C'est un seul branchement :
+ le rapiecer ouvrait tout, puisque l'API elle-meme ne demandait rien. Celle-ci
+ garde la porte LA OU SONT LES DONNEES — chaque route de PM.Router passe par
+ ApiLicenceOk avant son handler. Sauter l'ecran ne donne plus acces a rien.
+
+ AUCUNE FONCTION DE DECISION PARTAGEE AVEC PM.Licence. Si les deux couches
+ appelaient IsLicensed, rapiecer IsLicensed ouvrirait les deux d'un coup, et il
+ n'y aurait qu'une couche habillee en deux. Cette unite refait donc sa propre
+ verification de bout en bout — decodage, empreinte SHA-256, appel CNG, lecture
+ du champ machine — et ne prend a PM.Licence que des DONNEES : la cle publique
+ et le nom de la pref. Il faut trouver et rapiecer deux branchements distincts.
+
+ LE PRIX DE L'INDEPENDANCE : deux implementations qui doivent dire la meme
+ chose. Si elles divergeaient, un client licencie verrait l'ecran s'effacer
+ puis chaque requete tomber en 404 — le pire defaut possible, celui qui refuse
+ quelqu'un qui a paye. Deux garde-fous, et il faut savoir ce que chacun
+ couvre :
+ - SelfTestLicenceGate eprouve CETTE verification seule, sur un vecteur lie a
+ une empreinte fixe, et commence par le sens qui accepte — celui dont
+ l'echec coute le plus.
+ - L'ACCORD entre les deux couches ne se teste pas ici : la premiere compare a
+ l'empreinte reelle du poste, qui n'est pas un vecteur. Il se prouve en
+ lancant l'application avec une vraie licence — l'ecran passe (1re couche) ET
+ la connexion aboutit (2e couche).
+
+ CE QUE CA N'ARRETE PAS : quelqu'un qui trouve et rapiece les deux. Ca passe
+ d'une heure a deux ; ca ne change pas la nature d'un schema hors ligne.
+}
+
+interface
+
+uses
+ System.SysUtils;
+
+// L'API peut-elle servir ? Revenu vrai une fois, retenu pour le processus :
+// une licence valable ne cesse pas de l'etre en cours de route, et ce test se
+// trouve sur le chemin de CHAQUE requete. Un refus n'est jamais retenu — une
+// activation faite juste apres doit etre vue a la requete suivante.
+function ApiLicenceOk: Boolean;
+
+procedure SelfTestLicenceGate;
+
+implementation
+
+uses
+ System.JSON, System.Hash, System.NetEncoding, Winapi.Windows,
+ PM.UserPrefs, PM.MachineId, PM.Licence;
+
+// ===== CNG, redeclare ici expres ============================================
+//
+// Les memes fonctions que PM.Licence, declarees une seconde fois : les partager
+// ferait de leur appelant commun un point de rapiecage unique.
+
+const
+ GateStatusSuccess = NTSTATUS(0);
+ GateAlgEcdsaP256 = 'ECDSA_P256';
+ GateBlobEccPublic = 'ECCPUBLICBLOB';
+ GateBlobLen = 72;
+ GateSigLen = 64;
+
+function GateOpenAlg(out phAlgorithm: Pointer; pszAlgId, pszImplementation: PWideChar;
+ dwFlags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll' name 'BCryptOpenAlgorithmProvider';
+function GateCloseAlg(hAlgorithm: Pointer; dwFlags: ULONG): NTSTATUS; stdcall;
+ external 'bcrypt.dll' name 'BCryptCloseAlgorithmProvider';
+function GateImportKey(hAlgorithm, hImportKey: Pointer; pszBlobType: PWideChar;
+ out phKey: Pointer; pbInput: PByte; cbInput, dwFlags: ULONG): NTSTATUS; stdcall;
+ external 'bcrypt.dll' name 'BCryptImportKeyPair';
+function GateDestroyKey(hKey: Pointer): NTSTATUS; stdcall;
+ external 'bcrypt.dll' name 'BCryptDestroyKey';
+function GateVerifySig(hKey, pPaddingInfo: Pointer; pbHash: PByte; cbHash: ULONG;
+ pbSignature: PByte; cbSignature, dwFlags: ULONG): NTSTATUS; stdcall;
+ external 'bcrypt.dll' name 'BCryptVerifySignature';
+
+function GateB64Url(const AText: string): TBytes;
+var
+ S: string;
+begin
+ S := AText.Replace('-', '+').Replace('_', '/');
+ while Length(S) mod 4 <> 0 do
+ S := S + '=';
+ try
+ Result := TNetEncoding.Base64.DecodeStringToBytes(S);
+ except
+ Result := nil;
+ end;
+end;
+
+function GateSignatureHolds(const AKey, AMsg, ASig: TBytes): Boolean;
+var
+ LAlg, LKey: Pointer;
+ LSha: THashSHA2;
+ LDigest: TBytes;
+begin
+ Result := False;
+ if (Length(AKey) <> GateBlobLen) or (Length(ASig) <> GateSigLen) or
+ (Length(AMsg) = 0) then
+ Exit;
+ if GateOpenAlg(LAlg, GateAlgEcdsaP256, nil, 0) <> GateStatusSuccess then
+ Exit;
+ try
+ if GateImportKey(LAlg, nil, GateBlobEccPublic, LKey, @AKey[0],
+ GateBlobLen, 0) <> GateStatusSuccess then
+ Exit;
+ try
+ LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
+ LSha.Update(AMsg);
+ LDigest := LSha.HashAsBytes;
+ Result := GateVerifySig(LKey, nil, @LDigest[0], Length(LDigest),
+ @ASig[0], GateSigLen, 0) = GateStatusSuccess;
+ finally
+ GateDestroyKey(LKey);
+ end;
+ finally
+ GateCloseAlg(LAlg, 0);
+ end;
+end;
+
+// La meme regle d'acceptation que PM.Licence, reecrite : signature valable,
+// adresse presente, empreinte presente ET egale a celle attendue. AMachine est
+// un parametre pour que l'auto-test puisse la fixer — une empreinte reelle
+// change d'un poste a l'autre et ne ferait pas un vecteur.
+function GateAccepts(const AKey: TBytes; const ALicence, AMachine: string): Boolean;
+var
+ LDot: Integer;
+ LMsg, LSig: TBytes;
+ LVal: TJSONValue;
+ LMachine, LEmail: string;
+begin
+ Result := False;
+ LDot := Pos('.', ALicence);
+ if (LDot < 2) or (LDot = Length(ALicence)) then
+ Exit;
+ LMsg := GateB64Url(Copy(ALicence, 1, LDot - 1));
+ LSig := GateB64Url(Copy(ALicence, LDot + 1, MaxInt));
+ // La signature d'abord : on ne lit pas une charge utile qui ne vient pas de
+ // l'editeur.
+ if not GateSignatureHolds(AKey, LMsg, LSig) then
+ Exit;
+
+ LVal := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(LMsg));
+ if LVal = nil then
+ Exit;
+ try
+ if not (LVal is TJSONObject) then
+ Exit;
+ LEmail := TJSONObject(LVal).GetValue('e', '');
+ LMachine := LowerCase(TJSONObject(LVal).GetValue('m', ''));
+ finally
+ LVal.Free;
+ end;
+
+ Result := (LEmail <> '') and (LMachine <> '') and
+ SameText(LMachine, AMachine);
+end;
+
+var
+ GateOpen: Boolean = False;
+
+function ApiLicenceOk: Boolean;
+begin
+ if GateOpen then
+ Exit(True);
+ GateOpen := GateAccepts(LicencePublicKey,
+ Trim(GetPref(LicencePrefKey)),
+ MachineFingerprint);
+ Result := GateOpen;
+end;
+
+// ===== Auto-test =============================================================
+
+procedure SelfTestLicenceGate;
+const
+ // Paire jetable, privee jamais ecrite : la licence ci-dessous est liee a
+ // l'empreinte fixe 0123456789abcdef.
+ GateTestKey: array[0..71] of Byte = (
+ $45, $43, $53, $31, $20, $00, $00, $00, $5D, $7C, $98, $73,
+ $08, $66, $2E, $01, $38, $95, $9B, $57, $ED, $14, $E7, $15,
+ $D8, $54, $4B, $F9, $A9, $A0, $D8, $25, $44, $11, $75, $63,
+ $F5, $B8, $ED, $D0, $97, $13, $C3, $E6, $3D, $64, $B5, $D0,
+ $7F, $83, $2D, $8B, $E4, $2F, $56, $17, $A9, $7A, $1D, $90,
+ $EF, $95, $8A, $9F, $D4, $D3, $C5, $34, $22, $7B, $F3, $D8
+ );
+ GateTestLicence =
+ 'eyJuIjoiR2F0ZSIsImUiOiJnYXRlQHRlc3QubG9jYWwiLCJkIjoiMjAyNi0wOS0xNiIsIm0iOiIwMTIzNDU2Nzg5YWJjZGVmIn0.' +
+ '3LuJ7UGsEw3M3v6UmuUcjNRB5FWGF5Y8clLy1cKQtHXZ9O3foW7woVCuN1F9JD68N1DfILwFr2wT9OfUDt_pPw';
+ GateTestMachine = '0123456789abcdef';
+var
+ LKey: TBytes;
+ LTampered: string;
+begin
+ SetLength(LKey, GateBlobLen);
+ Move(GateTestKey[0], LKey[0], GateBlobLen);
+
+ // LE SENS QUI COMPTE LE PLUS : une deuxieme couche qui refuserait tout
+ // bloquerait chaque client licencie. Ce cas-la d'abord.
+ if not GateAccepts(LKey, GateTestLicence, GateTestMachine) then
+ raise Exception.Create('Auto-test 2e couche ECHOUE : une licence valable est refusee.');
+ if GateAccepts(LKey, GateTestLicence, 'ffffffffffffffff') then
+ raise Exception.Create('Auto-test 2e couche ECHOUE : une licence d''un autre poste est acceptee.');
+
+ LTampered := GateTestLicence;
+ LTampered[10] := Chr(Ord(LTampered[10]) xor 1);
+ if GateAccepts(LKey, LTampered, GateTestMachine) then
+ raise Exception.Create('Auto-test 2e couche ECHOUE : une licence trafiquee est acceptee.');
+
+ if GateAccepts(LKey, '', GateTestMachine) or
+ GateAccepts(LKey, 'nimportequoi', GateTestMachine) then
+ raise Exception.Create('Auto-test 2e couche ECHOUE : du charabia est accepte.');
+end;
+
+initialization
+ SelfTestLicenceGate;
+
+end.
diff --git a/delphi-backend/Source/PM.Router.pas b/delphi-backend/Source/PM.Router.pas
index ffbc940..86c8b20 100644
--- a/delphi-backend/Source/PM.Router.pas
+++ b/delphi-backend/Source/PM.Router.pas
@@ -44,6 +44,9 @@ var
implementation
+uses
+ PM.LicenceGate;
+
constructor TPMRouter.Create;
begin
inherited;
@@ -86,6 +89,17 @@ begin
LMatch := LRoute.Regex.Match(LPath);
if LMatch.Success then
begin
+ // DEUXIEME COUCHE DE LICENCE : ici, et seulement ici. Seules les routes
+ // de l'API arrivent a cette ligne — les fichiers de l'ecran d'activation
+ // passent par TryServeEmbedded, qui n'est pas garde, sinon l'ecran qui
+ // permet de s'activer ne pourrait plus se charger. 404 et non 403, comme
+ // le reste du verrouillage : ne pas dire ce qui manque.
+ if not ApiLicenceOk then
+ begin
+ AResponse.ResponseNo := 404;
+ AResponse.ContentText := '';
+ Exit(True);
+ end;
SetLength(LParams, LMatch.Groups.Count - 1);
for I := 1 to LMatch.Groups.Count - 1 do
LParams[I - 1] := LMatch.Groups[I].Value;