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;