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:
@@ -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}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user