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>
This commit is contained in:
r-zakarya
2026-09-16 22:55:33 +01:00
parent 5722347e75
commit 1fa5113c02
6 changed files with 304 additions and 2 deletions
+56
View File
@@ -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 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. 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 **ORDRE OBLIGATOIRE.** Tant que `LicencePublicKeyBlob` est a zero, aucune
licence n'est valide et l'ecran d'activation est **infranchissable** — l'exe 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 : 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 fix port-fixe est possible (`port=N` dans `config.txt`) mais on perd
l'aléatoire qui complique le scan externe. l'aléatoire qui complique le scan externe.
- **Modifier les `Enable*` props TMS en `OnInitialized`**, pas FormCreate. - **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\`. le coffre de `delphi-backend\` ni celui de `E: pmanager\`.
## Conventions code (héritage sessions précédentes) ## Conventions code (héritage sessions précédentes)
+1
View File
@@ -22,6 +22,7 @@ uses
PM.UserPrefs in 'Source\PM.UserPrefs.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas',
PM.MachineId in 'Source\PM.MachineId.pas', PM.MachineId in 'Source\PM.MachineId.pas',
PM.Licence in 'Source\PM.Licence.pas', PM.Licence in 'Source\PM.Licence.pas',
PM.LicenceGate in 'Source\PM.LicenceGate.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',
+1
View File
@@ -229,6 +229,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Source\PM.UserPrefs.pas"/> <DCCReference Include="Source\PM.UserPrefs.pas"/>
<DCCReference Include="Source\PM.MachineId.pas"/> <DCCReference Include="Source\PM.MachineId.pas"/>
<DCCReference Include="Source\PM.Licence.pas"/> <DCCReference Include="Source\PM.Licence.pas"/>
<DCCReference Include="Source\PM.LicenceGate.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"/>
+12 -2
View File
@@ -60,6 +60,13 @@ function ThisMachineId: string;
// l'initialisation de l'unite, comme SelfTestCrypto. // l'initialisation de l'unite, comme SelfTestCrypto.
procedure SelfTestLicence; 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 implementation
uses uses
@@ -67,8 +74,6 @@ uses
PM.UserPrefs, PM.MachineId; PM.UserPrefs, PM.MachineId;
const const
LicencePrefKey = 'licenceKey';
// ===== LA CLE PUBLIQUE DE L'EDITEUR ======================================== // ===== LA CLE PUBLIQUE DE L'EDITEUR ========================================
// //
// BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32). // BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32).
@@ -258,6 +263,11 @@ begin
Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen); Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen);
end; end;
function LicencePublicKey: TBytes;
begin
Result := ProductionKey;
end;
function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean; function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean;
begin begin
Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo) and Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo) and
+220
View File
@@ -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<string>('e', '');
LMachine := LowerCase(TJSONObject(LVal).GetValue<string>('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.
+14
View File
@@ -44,6 +44,9 @@ var
implementation implementation
uses
PM.LicenceGate;
constructor TPMRouter.Create; constructor TPMRouter.Create;
begin begin
inherited; inherited;
@@ -86,6 +89,17 @@ begin
LMatch := LRoute.Regex.Match(LPath); LMatch := LRoute.Regex.Match(LPath);
if LMatch.Success then if LMatch.Success then
begin 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); SetLength(LParams, LMatch.Groups.Count - 1);
for I := 1 to LMatch.Groups.Count - 1 do for I := 1 to LMatch.Groups.Count - 1 do
LParams[I - 1] := LMatch.Groups[I].Value; LParams[I - 1] := LMatch.Groups[I].Value;