From 1fa5113c02a4771a93c54d42009561ef962a9a1f Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:55:33 +0100 Subject: [PATCH] feat(licence): deuxieme couche, l'API refuse sans licence valable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 56 ++++++ delphi-backend/PMServer.dpr | 1 + delphi-backend/PMServer.dproj | 1 + delphi-backend/Source/PM.Licence.pas | 14 +- delphi-backend/Source/PM.LicenceGate.pas | 220 +++++++++++++++++++++++ delphi-backend/Source/PM.Router.pas | 14 ++ 6 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 delphi-backend/Source/PM.LicenceGate.pas 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;