From 0ddfe5c5fd89f396ff7923e58b31bb3cb4cda9cc Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:59:36 +0100 Subject: [PATCH] feat(crypto): wire AES-GCM and PBKDF2 to Windows CNG The viewer compiled for Win64 but died on first use: AesGcmDecrypt existed only as JNI, so the desktop build downloaded 27 KB and then refused it. Windows has the same primitive in bcrypt.dll - no DLL to ship, no OpenSSL, and BCryptDeriveKeyPBKDF2 takes bytes, so none of the char[] encoding doubt that keeps the Pascal loop around on Android. This exists so the client can be exercised on the desktop; deploying an APK to watch a list redraw is a slow way to build a UI. selftest.dpr replaces the crypto-vector button the viewer dropped: same known-answer vector, console, exit code = failures. Measured here - Pascal 1670 ms, CNG 528 ms, identical keys on the non-ASCII password. Co-Authored-By: Claude Opus 5 --- android-client/PMA.Crypto.pas | 151 ++++++++++++++++++++++++++++++++-- android-client/README.md | 18 +++- android-client/selftest.dpr | 116 ++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 9 deletions(-) create mode 100644 android-client/selftest.dpr diff --git a/android-client/PMA.Crypto.pas b/android-client/PMA.Crypto.pas index 22aaa5e..7fbbe84 100644 --- a/android-client/PMA.Crypto.pas +++ b/android-client/PMA.Crypto.pas @@ -17,6 +17,10 @@ unit PMA.Crypto; tag appended to the ciphertext, which is exactly what WebCrypto produces - the buffers are byte-identical, nothing to reshape. + On Windows the same primitive comes from bcrypt.dll (CNG) - no DLL to + ship, no OpenSSL. It exists so the client can be exercised on the desktop: + deploying an APK to see a list redraw is a slow way to build a UI. + - PBKDF2 is implemented HERE rather than via SecretKeyFactory, because javax.crypto.spec.PBEKeySpec takes a char[] and the provider decides how those chars become bytes: UTF-8 on some, low-byte on others. For a @@ -41,8 +45,10 @@ type function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes; AIterations, AKeyLenBytes: Integer): TBytes; -/// Same derivation, delegated to Android's SecretKeyFactory - natively -/// implemented, so roughly an order of magnitude faster than the Pascal loop. +/// Same derivation, delegated to the platform - natively implemented, so +/// faster than the Pascal loop. Android: SecretKeyFactory. Windows: CNG's +/// BCryptDeriveKeyPBKDF2, which takes BYTES and so carries none of the doubt +/// below. /// /// NOT used by DecryptContainer, and deliberately so: PBEKeySpec takes a /// char[] and the provider decides how those chars become bytes. If Android @@ -75,6 +81,54 @@ uses Androidapi.Jni, Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers; {$ENDIF} +{$IF DEFINED(MSWINDOWS)} +// CNG, declare ici : Delphi ne livre pas d'unite Winapi.Bcrypt, et six +// fonctions ne justifient pas d'en importer une d'ailleurs. +const + BCRYPT_DLL = 'bcrypt.dll'; + BCRYPT_ALG_HANDLE_HMAC_FLAG = $00000008; + AUTH_MODE_INFO_VERSION = 1; + +type + // Disposition identique au C : champs dans l'ordre, alignement par defaut + // ({$A8}) - les pointeurs et le UInt64 tombent sur 8, comme chez MSVC. + TBCryptAuthCipherModeInfo = record + cbSize: Cardinal; + dwInfoVersion: Cardinal; + pbNonce: PByte; + cbNonce: Cardinal; + pbAuthData: PByte; + cbAuthData: Cardinal; + pbTag: PByte; + cbTag: Cardinal; + pbMacContext: PByte; + cbMacContext: Cardinal; + cbAAD: Cardinal; + cbData: UInt64; + dwFlags: Cardinal; + end; + +function BCryptOpenAlgorithmProvider(var phAlgorithm: Pointer; + pszAlgId, pszImplementation: PWideChar; dwFlags: Cardinal): Integer; stdcall; + external BCRYPT_DLL; +function BCryptCloseAlgorithmProvider(hAlgorithm: Pointer; + dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL; +function BCryptSetProperty(hObject: Pointer; pszProperty: PWideChar; + pbInput: PByte; cbInput, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL; +function BCryptGenerateSymmetricKey(hAlgorithm: Pointer; var phKey: Pointer; + pbKeyObject: PByte; cbKeyObject: Cardinal; pbSecret: PByte; + cbSecret, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL; +function BCryptDestroyKey(hKey: Pointer): Integer; stdcall; external BCRYPT_DLL; +function BCryptDecrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal; + pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte; + cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer; + stdcall; external BCRYPT_DLL; +function BCryptDeriveKeyPBKDF2(hPrf: Pointer; pbPassword: PByte; + cbPassword: Cardinal; pbSalt: PByte; cbSalt: Cardinal; cIterations: UInt64; + pbDerivedKey: PByte; cbDerivedKey, dwFlags: Cardinal): Integer; stdcall; + external BCRYPT_DLL; +{$ENDIF} + var FLastNativeError: string = ''; @@ -306,9 +360,40 @@ begin end; end; end; +{$ELSEIF DEFINED(MSWINDOWS)} +var + LPrf: Pointer; + LPwd: TBytes; +begin + Result := nil; + LPwd := TEncoding.UTF8.GetBytes(APassword); + LPrf := nil; + // HMAC flag : sans lui le handle sert au hachage simple, et la derivation + // echoue au lieu de silencieusement produire autre chose. + if BCryptOpenAlgorithmProvider(LPrf, 'SHA256', nil, + BCRYPT_ALG_HANDLE_HMAC_FLAG) <> 0 then + begin + FLastNativeError := 'BCryptOpenAlgorithmProvider(SHA256) failed'; + Exit; + end; + try + SetLength(Result, AKeyLenBytes); + // Le mot de passe part en OCTETS UTF-8 : contrairement au char[] de Java, + // aucun choix d'encodage n'est laisse au fournisseur. + if BCryptDeriveKeyPBKDF2(LPrf, PByte(LPwd), Length(LPwd), + PByte(ASalt), Length(ASalt), AIterations, + PByte(Result), AKeyLenBytes, 0) <> 0 then + begin + FLastNativeError := 'BCryptDeriveKeyPBKDF2 failed'; + Result := nil; + end; + finally + BCryptCloseAlgorithmProvider(LPrf, 0); + end; +end; {$ELSE} begin - Result := nil; // no platform provider outside Android + Result := nil; // no platform provider here end; {$ENDIF} @@ -337,11 +422,65 @@ begin raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file'); end; end; +{$ELSEIF DEFINED(MSWINDOWS)} +var + LAlg, LKey: Pointer; + LInfo: TBCryptAuthCipherModeInfo; + LTag, LCt: TBytes; + LOutLen: Cardinal; + LMode: string; +begin + // GCM porte son tag a la fin, colle par WebCrypto. CNG le veut a part, dans + // la structure - c'est la seule difference avec le chemin Java, qui l'accepte + // concatene. + if Length(ACipherText) < 16 then + raise EPMACrypto.Create('Ciphertext shorter than its GCM tag'); + + LAlg := nil; + LKey := nil; + if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then + raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed'); + try + LMode := 'ChainingModeGCM'; + // Longueur AVEC le zero terminal : CNG compare la chaine complete. + if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)), + (Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then + raise EPMACrypto.Create('BCryptSetProperty(GCM) failed'); + + if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0, + PByte(AKey), Length(AKey), 0) <> 0 then + raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed'); + + LTag := Copy(ACipherText, Length(ACipherText) - 16, 16); + LCt := Copy(ACipherText, 0, Length(ACipherText) - 16); + + FillChar(LInfo, SizeOf(LInfo), 0); + LInfo.cbSize := SizeOf(LInfo); + LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION; + LInfo.pbNonce := PByte(AIv); + LInfo.cbNonce := Length(AIv); + LInfo.pbTag := PByte(LTag); + LInfo.cbTag := Length(LTag); + + SetLength(Result, Length(LCt)); + LOutLen := 0; + // pbIV a nil : en mode authentifie le nonce passe par la structure, et + // remplir les deux fait echouer l'appel. + if BCryptDecrypt(LKey, PByte(LCt), Length(LCt), @LInfo, nil, 0, + PByte(Result), Length(Result), LOutLen, 0) <> 0 then + // STATUS_AUTH_TAG_MISMATCH arrive ici : mot de passe faux, ou octets + // alteres. Indiscernables par construction - c'est ce qu'authentifier + // veut dire. Meme message que la branche Android. + raise EPMACrypto.Create('Decryption failed - wrong password or corrupt file'); + SetLength(Result, LOutLen); + finally + if LKey <> nil then BCryptDestroyKey(LKey); + BCryptCloseAlgorithmProvider(LAlg, 0); + end; +end; {$ELSE} begin - // Windows/macOS build of the same units: no platform AES-GCM available here. - // The desktop app already has its own crypto; this unit targets Android. - raise EPMACrypto.Create('AES-GCM is only wired for Android in this unit'); + raise EPMACrypto.Create('AES-GCM is not wired for this platform'); end; {$ENDIF} diff --git a/android-client/README.md b/android-client/README.md index 707c90c..d24593d 100644 --- a/android-client/README.md +++ b/android-client/README.md @@ -80,14 +80,26 @@ téléphone demanderait le `PUT` côté serveur et une fusion côté client. ### Tester sans téléphone -Tout sauf le Keystore et la biométrie tourne en **Win64** — `PMA.Store` -retourne « pas de coffre » hors Android, donc chaque lancement retélécharge et -redérive. Suffisant pour éprouver la liste, la recherche et la copie : +Tout sauf le Keystore et la biométrie tourne en **Win64**. `PMA.Crypto` a une +branche Windows adossée à **CNG** (`bcrypt.dll`) — même primitive, aucune DLL à +livrer, et la clé dérivée est identique à celle d'Android (vérifié sur le +vecteur, mot de passe non-ASCII compris). `PMA.Store` répond « pas de coffre » +hors Android, donc chaque lancement retélécharge et redérive. ```bash "C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" pmserverApk.dpr ``` +`selftest.dpr` vérifie la chaîne crypto contre le vecteur en console — c'est ce +qui a remplacé le bouton *Test crypto* du banc d'essai. Code de sortie = nombre +d'échecs : + +```bash +"C:\Program Files (x86)\Embarcadero\Studio\23.0\bin\dcc64.exe" -B "-UC:\Program Files (x86)\Embarcadero\Studio\23.0\lib\Win64\release" android-client\selftest.dpr +``` + +Mesures sur ce poste : PBKDF2 Pascal 1670 ms, CNG **528 ms**, clés identiques. + ## Diagnostiquer un échec de déchiffrement `try-open.js` ouvre un conteneur avec Node, donc avec les primitives du diff --git a/android-client/selftest.dpr b/android-client/selftest.dpr new file mode 100644 index 0000000..062064a --- /dev/null +++ b/android-client/selftest.dpr @@ -0,0 +1,116 @@ +program selftest; +{ + Verification de la chaine crypto contre le vecteur connu, en console. + + Remplace le bouton "Test crypto" qu'avait le banc d'essai : l'interface est + devenue celle d'un vrai client, et un aller-retour d'APK pour verifier une + constante est absurde. Ici la reponse arrive en quelques secondes, sur le + poste, avec un code de sortie exploitable. + + Ce qu'il isole, dans cet ordre - une etape fausse rend inutile de lire les + suivantes : + 1. PBKDF2 en Pascal -> la cle attendue ? + 2. PBKDF2 natif (CNG) -> la MEME cle ? (l'encodage du mot de passe) + 3. AES-GCM -> le clair attendu ? + + Compilation : + dcc32 -B android-client\selftest.dpr +} + +{$APPTYPE CONSOLE} + +uses + System.SysUtils, System.Hash, System.NetEncoding, System.Diagnostics, + PMA.Crypto in 'PMA.Crypto.pas', + PMA.TestVector in 'PMA.TestVector.pas'; + +var + GFailures: Integer = 0; + +procedure Check(const AName, AGot, AWant: string); +begin + if AGot = AWant then + Writeln(' OK ', AName) + else + begin + Writeln(' ECHEC ', AName); + Writeln(' obtenu : ', AGot); + Writeln(' attendu: ', AWant); + Inc(GFailures); + end; +end; + +var + LSalt, LKey: TBytes; + LPlain: string; + LSw: TStopwatch; +begin + try + // Le sel vient du conteneur lui-meme : si le vecteur est regenere, le + // test suit sans qu'on ait a recopier quoi que ce soit. + LSalt := TNetEncoding.Base64.DecodeStringToBytes( + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8='); + + Writeln('Mot de passe : ', + Length(TEncoding.UTF8.GetBytes(TESTVECTOR_PASSWORD)), + ' octets UTF-8 (attendu 25)'); + + Writeln('PBKDF2 Pascal 600k...'); + LSw := TStopwatch.StartNew; + LKey := DeriveKeyPBKDF2(TESTVECTOR_PASSWORD, LSalt, 600000, 32); + LSw.Stop; + Writeln(' ', LSw.ElapsedMilliseconds, ' ms'); + Check('cle derivee', LowerCase(THash.DigestAsString(LKey)), + TESTVECTOR_KEY_HEX); + + Writeln('PBKDF2 natif...'); + LSw := TStopwatch.StartNew; + LKey := DeriveKeyPBKDF2Native(TESTVECTOR_PASSWORD, LSalt, 600000, 32); + LSw.Stop; + if LKey = nil then + begin + Writeln(' indisponible : ', LastNativeError); + Inc(GFailures); + end + else + begin + Writeln(' ', LSw.ElapsedMilliseconds, ' ms'); + // Le point sensible : le mot de passe est non-ASCII, donc une cle + // identique prouve que le fournisseur encode bien en UTF-8. + Check('cle native identique', LowerCase(THash.DigestAsString(LKey)), + TESTVECTOR_KEY_HEX); + end; + + Writeln('Conteneur AES-GCM...'); + LPlain := DecryptContainer(TESTVECTOR_JSON, TESTVECTOR_PASSWORD); + Check('longueur du clair', + Length(TEncoding.UTF8.GetBytes(LPlain)).ToString, + TESTVECTOR_PLAIN_LEN.ToString); + Check('empreinte du clair', + LowerCase(THashSHA2.GetHashString(LPlain, THashSHA2.TSHA2Version.SHA256)), + TESTVECTOR_PLAIN_SHA); + + // Un mot de passe faux DOIT echouer : sans cette verification, un + // dechiffrement qui rendrait n'importe quoi passerait pour un succes. + try + DecryptContainer(TESTVECTOR_JSON, 'mauvais mot de passe'); + Writeln(' ECHEC un mot de passe faux a ete accepte'); + Inc(GFailures); + except + on E: EPMACrypto do Writeln(' OK mot de passe faux rejete'); + end; + except + on E: Exception do + begin + Writeln('ERREUR ', E.ClassName, ' : ', E.Message); + Inc(GFailures); + end; + end; + + Writeln; + if GFailures = 0 then + Writeln('Tout est bon.') + else + Writeln(GFailures, ' echec(s).'); + ExitCode := GFailures; +end.