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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-15 05:59:36 +01:00
parent bf7aac478b
commit 0ddfe5c5fd
3 changed files with 276 additions and 9 deletions
+145 -6
View File
@@ -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}