diff --git a/CLAUDE.md b/CLAUDE.md index 6168c7d..2db8682 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,6 +245,52 @@ Détails : Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se sert dans les couleurs système. +## Licences (`PM.Licence`) + +Verification **hors ligne** d'une licence signee ECDSA P-256. Une licence est +une chaine `charge-utile.signature`, chacune en base64url, fabriquee par +`tools/licence.ps1` avec la cle privee qui reste chez l'editeur. Seule la cle +**publique** est dans l'exe : elle ne permet que de verifier. + +``` +tools\licence.ps1 -Init # une seule fois : cree la paire, + # imprime la constante Delphi +tools\licence.ps1 -For "a@b.com" -Name "Ami" # une licence +``` + +- **`LicencePublicKeyBlob`** (72 octets) est un `BCRYPT_ECCPUBLIC_BLOB` : + magic `ECS1` + longueur(32) + X(32) + Y(32). **Livre a zero** — tant qu'on + n'y a pas colle la sortie de `-Init`, aucune licence n'est valide. Ne jamais + regenerer la paire ensuite : toutes les licences deja emises tomberaient. +- **Aucun ASN.1 nulle part.** .NET sur Windows EST CNG : la signature produite + par le script (`r||s` brut, 64 octets) est deja celle qu'attend + `BCryptVerifySignature`, et les coordonnees exportees se rangent telles + quelles dans le blob. Le decodage DER, moitie penible de l'exercice + d'habitude, n'existe pas ici. +- `BCryptVerifySignature` attend **l'empreinte**, pas le message : SHA-256 est + calcule avant l'appel (`THashSHA2.Create` + `Update`, pas `GetHashBytes` qui + n'a pas de surcharge `TBytes`). +- **La signature d'abord, la lecture ensuite** : `SplitAndVerify` n'analyse + jamais une charge utile dont il ne sait pas encore qu'elle vient de nous. +- `IsLicensed` **REVERIFIE** la licence rangee au lieu de lire un drapeau : un + drapeau se retourne, une signature se refabrique seulement avec la cle privee. + Resultat mis en cache en memoire, invalide par `ActivateLicence`/`ClearLicence`. +- Rangee en pref DPAPI (`licenceKey`) — liee au compte Windows, survit au + changement de port comme le reste. +- **Pas d'expiration dans la charge utile**, volontairement : une date qu'on + fait respecter avec l'horloge de la machine du client ne prouve rien. Le + champ `d` n'est qu'une date d'emission. +- `SelfTestLicence` a l'`initialization` (comme `SelfTestCrypto`) rejoue un + vecteur fige signe par une **paire de test** dont la privee a ete jetee : + licence valable acceptee, un octet retourne dans la charge utile refuse. + `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. + +**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 +fonction sur un serveur, ce qui contredit le « 100 % hors ligne ». + ## Debug mode Fichier `config.txt` à côté de l'exe (tous champs optionnels) : diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr index 31d61bf..b9ccc14 100644 --- a/delphi-backend/PMServer.dpr +++ b/delphi-backend/PMServer.dpr @@ -20,6 +20,7 @@ uses PM.QuickUnlock in 'Source\PM.QuickUnlock.pas', PM.PinUnlock in 'Source\PM.PinUnlock.pas', PM.UserPrefs in 'Source\PM.UserPrefs.pas', + PM.Licence in 'Source\PM.Licence.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 3f83454..ebf12a8 100644 --- a/delphi-backend/PMServer.dproj +++ b/delphi-backend/PMServer.dproj @@ -227,6 +227,7 @@ $(PreBuildEvent)]]> + diff --git a/delphi-backend/Source/PM.Licence.pas b/delphi-backend/Source/PM.Licence.pas new file mode 100644 index 0000000..f1c5bf0 --- /dev/null +++ b/delphi-backend/Source/PM.Licence.pas @@ -0,0 +1,319 @@ +unit PM.Licence; + +{ + Verification hors ligne d'une licence signee ECDSA P-256. + + Une licence est une chaine "charge-utile.signature", chacune en base64url, + fabriquee par tools\licence.ps1 avec la cle privee qui reste chez l'editeur. + La cle PUBLIQUE seule est ici, en dur : elle ne permet que de verifier. + + CE QUE CA ARRETE, ET CE QUE CA N'ARRETE PAS. Sans la cle privee, personne ne + fabrique de licence valable — c'est tout ce qu'un schema local peut promettre. + Rien n'empeche de rapiecer l'exe pour sauter l'appel : c'est vrai de tous les + schemas hors ligne, y compris ceux qui se vendent cher. La seule parade reelle + serait de deporter une fonction sur un serveur, ce qui contredit le "100 % + hors ligne" de cette application. + + POURQUOI AUCUN ASN.1 NULLE PART. .NET sur Windows EST CNG : la signature que + produit le script (r||s brut, 64 octets) est deja celle qu'attend + BCryptVerifySignature, et les coordonnees X/Y exportees se rangent telles + quelles dans un BCRYPT_ECCPUBLIC_BLOB. Le decodage DER, moitie penible de + l'exercice d'habitude, n'existe pas ici. + + La licence acceptee est rangee en pref DPAPI (PM.UserPrefs), donc liee au + compte Windows et survivant au changement de port comme le reste. +} + +interface + +uses + System.SysUtils, System.Classes; + +type + TLicenceInfo = record + Holder: string; // nom affichable + Email: string; + IssuedOn: string; // 'yyyy-mm-dd', date d'emission (PAS une expiration) + end; + +// Verifie une chaine de licence sans rien ecrire. False = signature invalide, +// charge utile trafiquee, format casse, ou cle publique pas encore posee. +function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean; + +// Verifie puis range la licence si elle tient. C'est le point d'entree du +// bouton "Activer". +function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean; + +// Etat courant : relit la licence rangee et la REVERIFIE. Ne se contente +// jamais d'un drapeau booleen stocke — un drapeau se retourne, une signature +// se refabrique seulement avec la cle privee. +function IsLicensed: Boolean; +function LicenceInfo: TLicenceInfo; +procedure ClearLicence; + +// Verifie le chemin complet contre un vecteur de test fige. Appele a +// l'initialisation de l'unite, comme SelfTestCrypto. +procedure SelfTestLicence; + +implementation + +uses + System.JSON, System.Hash, System.NetEncoding, Winapi.Windows, PM.UserPrefs; + +const + LicencePrefKey = 'licenceKey'; + + // ===== LA CLE PUBLIQUE DE L'EDITEUR ======================================== + // + // BCRYPT_ECCPUBLIC_BLOB : magic 'ECS1' + longueur(32) + X(32) + Y(32). + // + // TOUT A ZERO = AUCUNE LICENCE NE SERA JAMAIS VALIDE. Lance + // powershell -ExecutionPolicy Bypass -File tools\licence.ps1 -Init + // et colle ici les 72 octets qu'il imprime. Ne regenere JAMAIS la paire + // ensuite : toutes les licences deja emises tomberaient d'un coup. + LicencePublicKeyBlob: array[0..71] of Byte = ( + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, + $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00, $00 + ); + +// ===== CNG =================================================================== + +const + STATUS_SUCCESS = NTSTATUS(0); + BCRYPT_ECDSA_P256 = 'ECDSA_P256'; + BCRYPT_ECCPUBLIC = 'ECCPUBLICBLOB'; + EccBlobLen = 72; + P256SignatureLen = 64; // r||s, 32 octets chacun + +function BCryptOpenAlgorithmProvider(out phAlgorithm: Pointer; + pszAlgId, pszImplementation: PWideChar; dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptOpenAlgorithmProvider'; + +function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer; + dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptCloseAlgorithmProvider'; + +function BCryptImportKeyPair(hAlgorithm, hImportKey: Pointer; + pszBlobType: PWideChar; out phKey: Pointer; pbInput: PByte; + cbInput, dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptImportKeyPair'; + +function BCryptDestroyKey(hKey: Pointer): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptDestroyKey'; + +function BCryptVerifySignature(hKey, pPaddingInfo: Pointer; + pbHash: PByte; cbHash: ULONG; pbSignature: PByte; + cbSignature, dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptVerifySignature'; + +// ===== Outils ================================================================ + +function Base64UrlDecode(const AText: string): TBytes; +var + LStd: string; +begin + LStd := StringReplace(AText, '-', '+', [rfReplaceAll]); + LStd := StringReplace(LStd, '_', '/', [rfReplaceAll]); + while (Length(LStd) mod 4) <> 0 do + LStd := LStd + '='; + try + Result := TNetEncoding.Base64.DecodeStringToBytes(LStd); + except + // Une chaine recopiee de travers n'est pas une panne : c'est un refus. + Result := nil; + end; +end; + +// Verifie contre UNE cle publique donnee. La production passe la constante +// ci-dessus ; l'auto-test passe la sienne, ce qui evite de figer un second +// jeu de constantes pour la meme mecanique. +function VerifyAgainstKey(const AKeyBlob: TBytes; const AMessage, + ASignature: TBytes): Boolean; +var + LAlg, LKey: Pointer; + LHash: TBytes; + LSha: THashSHA2; +begin + Result := False; + if (Length(AKeyBlob) <> EccBlobLen) or + (Length(ASignature) <> P256SignatureLen) or (Length(AMessage) = 0) then + Exit; + + if BCryptOpenAlgorithmProvider(LAlg, BCRYPT_ECDSA_P256, nil, 0) <> STATUS_SUCCESS then + Exit; + try + if BCryptImportKeyPair(LAlg, nil, BCRYPT_ECCPUBLIC, LKey, + @AKeyBlob[0], EccBlobLen, 0) <> STATUS_SUCCESS then + Exit; + try + // BCryptVerifySignature attend l'EMPREINTE, pas le message. + LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + LSha.Update(AMessage); + LHash := LSha.HashAsBytes; + Result := BCryptVerifySignature(LKey, nil, @LHash[0], Length(LHash), + @ASignature[0], P256SignatureLen, 0) = STATUS_SUCCESS; + finally + BCryptDestroyKey(LKey); + end; + finally + BCryptCloseAlgorithmProvider(LAlg, 0); + end; +end; + +function ParsePayload(const APayload: TBytes; out AInfo: TLicenceInfo): Boolean; +var + LVal: TJSONValue; + LObj: TJSONObject; +begin + Result := False; + AInfo := Default(TLicenceInfo); + LVal := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(APayload)); + if LVal = nil then + Exit; + try + // Un transtypage direct leverait sur une charge utile qui n'est pas un + // objet, et fuirait la valeur au passage. + if not (LVal is TJSONObject) then + Exit; + LObj := TJSONObject(LVal); + AInfo.Holder := LObj.GetValue('n', ''); + AInfo.Email := LObj.GetValue('e', ''); + AInfo.IssuedOn := LObj.GetValue('d', ''); + Result := AInfo.Email <> ''; + finally + LVal.Free; + end; +end; + +function SplitAndVerify(const AKeyBlob: TBytes; const ALicence: string; + out AInfo: TLicenceInfo): Boolean; +var + LDot: Integer; + LMsg, LSig: TBytes; +begin + Result := False; + AInfo := Default(TLicenceInfo); + + LDot := Pos('.', ALicence); + if (LDot < 2) or (LDot = Length(ALicence)) then + Exit; + + LMsg := Base64UrlDecode(Copy(ALicence, 1, LDot - 1)); + LSig := Base64UrlDecode(Copy(ALicence, LDot + 1, MaxInt)); + if not VerifyAgainstKey(AKeyBlob, LMsg, LSig) then + Exit; + + // La signature d'abord, la lecture ensuite : on n'analyse jamais une charge + // utile dont on ne sait pas encore qu'elle vient de nous. + Result := ParsePayload(LMsg, AInfo); +end; + +// ===== API =================================================================== + +var + CachedChecked: Boolean = False; + CachedValid: Boolean = False; + CachedInfo: TLicenceInfo; + +function ProductionKey: TBytes; +begin + SetLength(Result, EccBlobLen); + Move(LicencePublicKeyBlob[0], Result[0], EccBlobLen); +end; + +function VerifyLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean; +begin + Result := SplitAndVerify(ProductionKey, Trim(ALicence), AInfo); +end; + +function ActivateLicence(const ALicence: string; out AInfo: TLicenceInfo): Boolean; +begin + Result := VerifyLicence(ALicence, AInfo); + if Result then + begin + SetPref(LicencePrefKey, Trim(ALicence)); + CachedChecked := True; + CachedValid := True; + CachedInfo := AInfo; + end; +end; + +procedure Refresh; +var + LStored: string; +begin + if CachedChecked then + Exit; + CachedChecked := True; + LStored := GetPref(LicencePrefKey); + CachedValid := (LStored <> '') and VerifyLicence(LStored, CachedInfo); + if not CachedValid then + CachedInfo := Default(TLicenceInfo); +end; + +function IsLicensed: Boolean; +begin + Refresh; + Result := CachedValid; +end; + +function LicenceInfo: TLicenceInfo; +begin + Refresh; + Result := CachedInfo; +end; + +procedure ClearLicence; +begin + SetPref(LicencePrefKey, ''); + CachedChecked := True; + CachedValid := False; + CachedInfo := Default(TLicenceInfo); +end; + +// ===== Auto-test ============================================================= + +procedure SelfTestLicence; +const + // Paire de test, sans rapport avec la cle de production : sa privee a ete + // jetee. Elle ne prouve qu'une chose, mais elle la prouve entierement — que + // le blob, l'empreinte et le format r||s s'accordent avec CNG. + TestKey: array[0..71] of Byte = ( + $45, $43, $53, $31, $20, $00, $00, $00, $27, $24, $81, $FF, + $7E, $21, $27, $90, $91, $3D, $B7, $7E, $6E, $20, $18, $8B, + $48, $51, $D2, $DD, $1A, $BB, $3D, $E8, $65, $06, $55, $AD, + $A3, $93, $EE, $6D, $D4, $56, $42, $9D, $F7, $85, $3C, $C9, + $98, $FA, $42, $43, $47, $E6, $2F, $2B, $9B, $30, $6B, $15, + $88, $AA, $2D, $C3, $79, $2B, $1E, $A2, $4D, $1F, $E3, $55 + ); + TestLicence = 'eyJuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' + + 'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA'; +var + LKey: TBytes; + LInfo: TLicenceInfo; + LTampered: string; +begin + SetLength(LKey, EccBlobLen); + Move(TestKey[0], LKey[0], EccBlobLen); + + if not SplitAndVerify(LKey, TestLicence, LInfo) then + raise Exception.Create('Auto-test licence ECHOUE : une licence valable est refusee.'); + if LInfo.Email <> 'ami@exemple.com' then + raise Exception.CreateFmt( + 'Auto-test licence ECHOUE : charge utile mal lue (got "%s").', [LInfo.Email]); + + // Un octet retourne dans la charge utile doit faire tomber la signature. + LTampered := 'e0JuIjoiTW9uIEFtaSIsImUiOiJhbWlAZXhlbXBsZS5jb20iLCJkIjoiMjAyNi0wOS0wNSJ9.' + + 'u9q3GvBByVU0pLbuRrgT98-kZqOBSnYptJ2Mx1B1OjXxJAFgNgnxyjj8SmYpkFt1GuP2PlzRXB3A3HYRMwW2gA'; + if SplitAndVerify(LKey, LTampered, LInfo) then + raise Exception.Create('Auto-test licence ECHOUE : une licence trafiquee est acceptee.'); +end; + +initialization + SelfTestLicence; + +end.