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}
+15 -3
View File
@@ -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
+116
View File
@@ -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.