Files
Password-Manager/android-client/PMA.Crypto.pas
T
r-zakarya f245cdcc38 perf(android): one key derivation per save, not two
Reported as "Save takes 6 to 8 seconds". A save decrypts the snapshot it just
pulled and re-encrypts the patched one - with the same password. That was two
full PBKDF2 runs at 600k iterations, four to six seconds each on this phone,
for two keys that protect the same file.

DecryptContainerEx now hands back the key, its salt and the iteration count,
and EncryptContainerWithKey reuses them. The IV is still drawn fresh every
time: reusing an IV with the same key breaks GCM entirely, whereas reusing the
salt merely yields the same key - which is the whole point. A salt slows an
attack on the password; it has no reason to differ between two versions of the
same file.

EncryptContainer keeps its old shape and now delegates, so there is a single
place that composes a container.

The log times both halves, so the next measurement says where the remaining
seconds are rather than inviting another guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 19:50:18 +01:00

779 lines
30 KiB
ObjectPascal

unit PMA.Crypto;
{
Opens a pm-encrypted-export-v1 container (the vault snapshot) on Android.
Format, produced by js/app.import.js encryptExportPayload():
kdf 'pbkdf2-sha256'
kdf_iterations 600000
kdf_salt base64, 32 bytes
iv base64, 12 bytes
ciphertext base64, AES-GCM output = ciphertext || 16-byte tag
(payload) UTF-8 JSON
TWO DIFFERENT ROUTES, on purpose:
- AES-GCM goes through JNI to javax.crypto. One call, no loop, and the
platform implementation is hardware-accelerated. Java's Cipher expects the
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
non-ASCII sync password the derived key would differ from the browser's,
and the failure is silent - "wrong password" on a correct password. Doing
it here fixes the encoding at UTF-8 and makes the match exact by
construction. The cost is 600k HMAC-SHA256 rounds in Pascal; measure it on
a real device (see DeriveKeyPBKDF2's note) before considering the JNI
route again.
}
interface
uses
System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash,
// System.DateUtils : TTimeZone, pour horodater le conteneur en UTC.
System.DateUtils;
type
EPMACrypto = class(Exception);
/// Derive a key the same way WebCrypto's deriveExportKey does: PBKDF2-SHA256
/// over the UTF-8 bytes of the password. AKeyLenBytes = 32 for AES-256.
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// 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
/// encodes them as anything other than UTF-8, this returns a DIFFERENT key for
/// a non-ASCII password - silently, reported later as a wrong password.
/// The self-test compares it against the known vector on a real device; adopt
/// it only if they match there. Returns nil on any failure.
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// Why the last DeriveKeyPBKDF2Native returned nil. Empty when it succeeded.
function LastNativeError: string;
/// AES-256-GCM decrypt. ACipherText carries the 16-byte tag at the end, as
/// produced by WebCrypto. Raises EPMACrypto when the tag does not verify -
/// which is what a wrong password looks like.
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
/// Full container -> plaintext JSON payload. Validates the format marker so a
/// truncated download or an HTML error page fails with a clear message rather
/// than as "wrong password".
function DecryptContainer(const AContainerJson, APassword: string): string;
/// N octets d'un generateur CRYPTOGRAPHIQUE - SecureRandom sur Android,
/// BCryptGenRandom sur Windows. JAMAIS Random : un sel ou un IV previsible
/// ruine le chiffrement qui les emploie, et rien dans le resultat ne le
/// montrerait. Leve plutot que de rendre des octets faibles.
function SecureRandomBytes(ACount: Integer): TBytes;
/// AES-256-GCM chiffrement. Le tag de 16 octets est colle A LA FIN du
/// resultat, comme le fait WebCrypto - c'est ce que DecryptContainer et le
/// poste Windows attendent tous les deux.
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
/// Texte JSON -> conteneur complet, dans le format exact que le poste ecrit
/// (`pm-encrypted-export-v1`). Sel et IV tires a chaque appel : deux
/// chiffrements du meme coffre ne se ressemblent pas, et c'est voulu.
function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer = 600000): string;
/// Comme DecryptContainer, mais rend AUSSI la cle derivee, son sel et le
/// nombre d'iterations. Sert au seul cas ou l'on rechiffre juste apres avoir
/// dechiffre : deriver deux fois le meme mot de passe coute deux fois quatre a
/// six secondes sur un telephone, pour un resultat identique.
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
/// Rechiffre avec une cle DEJA derivee. L'IV, lui, est retire a chaque appel :
/// reutiliser un IV avec la meme cle casse GCM entierement, alors que
/// reutiliser le sel ne fait que redonner la meme cle - ce qui est justement
/// le but.
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
implementation
{$IF DEFINED(ANDROID)}
uses
// Androidapi.Jni : sans elle, TJavaArray.Length reste un appel non inline
// et le compilateur emet un avertissement a chaque usage.
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 BCryptEncrypt(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 BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte;
cbBuffer: Cardinal; dwFlags: Cardinal): 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 = '';
function LastNativeError: string;
begin
Result := FLastNativeError;
end;
// ---------------------------------------------------------------------------
// PBKDF2-SHA256
// ---------------------------------------------------------------------------
function XorBytes(const A, B: TBytes): TBytes;
var
I: Integer;
begin
SetLength(Result, Length(A));
for I := 0 to High(A) do
Result[I] := A[I] xor B[I];
end;
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
// Straight RFC 2898. One block is enough for a 32-byte key with SHA-256
// (hLen = 32), so there is no block loop to get wrong.
//
// PERFORMANCE: 600k HMAC rounds. Budget a few seconds on a mid-range phone and
// run it OFF the UI thread. If it proves unacceptable, the answer is to cache
// the derived key in the Android Keystore behind biometrics - NOT to lower the
// iteration count, which is shared with the desktop and defines the format.
var
LPwd, LBlock, LU, LT: TBytes;
I: Integer;
begin
if AKeyLenBytes > 32 then
raise EPMACrypto.Create('KeyLen > one SHA-256 block is not implemented');
LPwd := TEncoding.UTF8.GetBytes(APassword);
// U1 = HMAC(pwd, salt || INT32BE(1))
SetLength(LBlock, Length(ASalt) + 4);
if Length(ASalt) > 0 then
Move(ASalt[0], LBlock[0], Length(ASalt));
LBlock[Length(ASalt)] := 0;
LBlock[Length(ASalt) + 1] := 0;
LBlock[Length(ASalt) + 2] := 0;
LBlock[Length(ASalt) + 3] := 1;
LU := THashSHA2.GetHMACAsBytes(LBlock, LPwd, THashSHA2.TSHA2Version.SHA256);
LT := Copy(LU, 0, Length(LU));
for I := 2 to AIterations do
begin
LU := THashSHA2.GetHMACAsBytes(LU, LPwd, THashSHA2.TSHA2Version.SHA256);
LT := XorBytes(LT, LU);
end;
Result := Copy(LT, 0, AKeyLenBytes);
end;
// ---------------------------------------------------------------------------
// AES-GCM through javax.crypto
// ---------------------------------------------------------------------------
{$IF DEFINED(ANDROID)}
type
// Minimal JNI imports. Delphi ships no headers for javax.crypto, and the
// bridge builds its method signatures from these declarations - hence the
// real Java types (Key, AlgorithmParameterSpec) rather than JObject, which
// would generate a signature no method matches.
JKey = interface;
JAlgorithmParameterSpec = interface;
JSecretKeySpec = interface;
JGCMParameterSpec = interface;
JCipher = interface;
JKeyClass = interface(IJavaClass)
['{6E0F5B9A-1C2D-4E3F-8A7B-9C0D1E2F3A4B}']
end;
[JavaSignature('java/security/Key')]
JKey = interface(IJavaInstance)
['{7F1A6CAB-2D3E-4F50-9B8C-AD1E2F3A4B5C}']
end;
TJKey = class(TJavaGenericImport<JKeyClass, JKey>) end;
JAlgorithmParameterSpecClass = interface(IJavaClass)
['{8A2B7DBC-3E4F-5061-AC9D-BE2F3A4B5C6D}']
end;
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
JAlgorithmParameterSpec = interface(IJavaInstance)
['{9B3C8ECD-4F50-6172-BDAE-CF3A4B5C6D7E}']
end;
TJAlgorithmParameterSpec = class(TJavaGenericImport<JAlgorithmParameterSpecClass, JAlgorithmParameterSpec>) end;
JSecretKeySpecClass = interface(JKeyClass)
['{AC4D9FDE-5061-7283-CEBF-DA4B5C6D7E8F}']
function init(key: TJavaArray<Byte>; algorithm: JString): JSecretKeySpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/SecretKeySpec')]
JSecretKeySpec = interface(JKey)
['{BD5EA0EF-6172-8394-DFCA-EB5C6D7E8F90}']
end;
TJSecretKeySpec = class(TJavaGenericImport<JSecretKeySpecClass, JSecretKeySpec>) end;
JGCMParameterSpecClass = interface(JAlgorithmParameterSpecClass)
['{CE6FB1F0-7283-94A5-E0DB-FC6D7E8F9001}']
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
JGCMParameterSpec = interface(JAlgorithmParameterSpec)
['{DF70C201-8394-A5B6-F1EC-0D7E8F900112}']
end;
TJGCMParameterSpec = class(TJavaGenericImport<JGCMParameterSpecClass, JGCMParameterSpec>) end;
JKeySpec = interface;
JPBEKeySpec = interface;
JSecretKey = interface;
JSecretKeyFactory = interface;
// generateSecret takes a KeySpec, not an Object. Declaring the real
// interface is what makes the bridge emit
// (Ljava/security/spec/KeySpec;)Ljavax/crypto/SecretKey; - with JObject it
// emitted a signature no method matches, and the call failed at runtime.
JKeySpecClass = interface(IJavaClass)
['{0A1B2C3D-4E5F-6071-8293-A4B5C6D7E8F9}']
end;
[JavaSignature('java/security/spec/KeySpec')]
JKeySpec = interface(IJavaInstance)
['{1B2C3D4E-5F60-7182-93A4-B5C6D7E8F901}']
end;
TJKeySpec = class(TJavaGenericImport<JKeySpecClass, JKeySpec>) end;
JPBEKeySpecClass = interface(JKeySpecClass)
['{1A2B3C4D-5E6F-7081-92A3-B4C5D6E7F801}']
function init(password: TJavaArray<Char>; salt: TJavaArray<Byte>;
iterationCount: Integer; keyLength: Integer): JPBEKeySpec; cdecl;
end;
[JavaSignature('javax/crypto/spec/PBEKeySpec')]
JPBEKeySpec = interface(JKeySpec)
['{2B3C4D5E-6F70-8192-A3B4-C5D6E7F80112}']
end;
TJPBEKeySpec = class(TJavaGenericImport<JPBEKeySpecClass, JPBEKeySpec>) end;
JSecretKeyClass = interface(JKeyClass)
['{3C4D5E6F-7081-92A3-B4C5-D6E7F8011223}']
end;
[JavaSignature('javax/crypto/SecretKey')]
JSecretKey = interface(JKey)
['{4D5E6F70-8192-A3B4-C5D6-E7F801122334}']
function getEncoded: TJavaArray<Byte>; cdecl;
end;
TJSecretKey = class(TJavaGenericImport<JSecretKeyClass, JSecretKey>) end;
JSecretKeyFactoryClass = interface(JObjectClass)
['{5E6F7081-92A3-B4C5-D6E7-F80112233445}']
function getInstance(algorithm: JString): JSecretKeyFactory; cdecl;
end;
[JavaSignature('javax/crypto/SecretKeyFactory')]
JSecretKeyFactory = interface(JObject)
['{6F708192-A3B4-C5D6-E7F8-011223344556}']
function generateSecret(keySpec: JKeySpec): JSecretKey; cdecl;
end;
TJSecretKeyFactory = class(TJavaGenericImport<JSecretKeyFactoryClass, JSecretKeyFactory>) end;
JCipherClass = interface(JObjectClass)
['{E081D312-94A5-B6C7-02FD-1E8F90011223}']
function _GetDECRYPT_MODE: Integer; cdecl;
function _GetENCRYPT_MODE: Integer; cdecl;
function getInstance(transformation: JString): JCipher; cdecl;
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
end;
[JavaSignature('javax/crypto/Cipher')]
JCipher = interface(JObject)
['{F192E423-A5B6-C7D8-130E-2F9001122334}']
procedure init(opmode: Integer; key: JKey; params: JAlgorithmParameterSpec); cdecl;
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
end;
TJCipher = class(TJavaGenericImport<JCipherClass, JCipher>) end;
// Declare ICI plutot qu'en important Androidapi.JNI.Java.Security : cette
// unite redeclarerait JKey et consorts, et Delphi resout par le dernier uses
// - de quoi relier silencieusement le code existant a d'autres interfaces.
JSecureRandom = interface;
JSecureRandomClass = interface(JObjectClass)
['{9C1D2E3F-4A5B-6C7D-8E9F-0A1B2C3D4E5F}']
function init: JSecureRandom; cdecl;
end;
[JavaSignature('java/security/SecureRandom')]
JSecureRandom = interface(JObject)
['{AD2E3F40-5B6C-7D8E-9F0A-1B2C3D4E5F60}']
procedure nextBytes(bytes: TJavaArray<Byte>); cdecl;
end;
TJSecureRandom = class(TJavaGenericImport<JSecureRandomClass, JSecureRandom>) end;
function ToJavaBytes(const B: TBytes): TJavaArray<Byte>;
var
I: Integer;
begin
Result := TJavaArray<Byte>.Create(Length(B));
for I := 0 to High(B) do
Result.Items[I] := B[I];
end;
function FromJavaBytes(const A: TJavaArray<Byte>): TBytes;
var
I: Integer;
begin
SetLength(Result, A.Length);
for I := 0 to A.Length - 1 do
Result[I] := A.Items[I];
end;
{$ENDIF}
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
{$IF DEFINED(ANDROID)}
var
LChars: TJavaArray<Char>;
LSpec: JPBEKeySpec;
LFactory: JSecretKeyFactory;
I: Integer;
begin
Result := nil;
try
// The password goes in as chars - the whole point of the doubt. Delphi
// strings are UTF-16, so this hands the provider the code units and lets
// it apply whatever encoding it wants. That is exactly what the test
// measures against the vector.
LChars := TJavaArray<Char>.Create(Length(APassword));
for I := 1 to Length(APassword) do
LChars.Items[I - 1] := APassword[I];
LSpec := TJPBEKeySpec.JavaClass.init(LChars, ToJavaBytes(ASalt),
AIterations, AKeyLenBytes * 8); // keyLength is in BITS
LFactory := TJSecretKeyFactory.JavaClass.getInstance(
StringToJString('PBKDF2WithHmacSHA256'));
Result := FromJavaBytes(LFactory.generateSecret(LSpec).getEncoded);
except
// Keep the reason: "unavailable" hid a signature mismatch of mine for a
// whole test round. A message here says whether Android refused, or
// whether the JNI declaration is wrong again.
on E: Exception do
begin
FLastNativeError := E.ClassName + ': ' + E.Message;
Result := nil;
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 here
end;
{$ENDIF}
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
{$IF DEFINED(ANDROID)}
var
LCipher: JCipher;
LKeySpec: JSecretKeySpec;
LGcm: JGCMParameterSpec;
begin
try
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
StringToJString('AES'));
// 128 = tag length in BITS. WebCrypto's default is the same 16 bytes; a
// mismatch here fails authentication on otherwise valid data.
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipher.JavaClass.DECRYPT_MODE, LKeySpec, LGcm);
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText)));
except
// AEADBadTagException surfaces here: wrong password, or altered bytes.
// Both are indistinguishable by design - that is what authentication means.
on E: Exception do
// Message en pur ASCII : un tiret cadratin dans une source lue en ANSI
// par Delphi ressort en mojibake sur l'appareil.
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
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
end;
{$ENDIF}
// ---------------------------------------------------------------------------
// Alea et chiffrement
// ---------------------------------------------------------------------------
function SecureRandomBytes(ACount: Integer): TBytes;
{$IF DEFINED(ANDROID)}
var
LRnd: JSecureRandom;
LArr: TJavaArray<Byte>;
begin
if ACount <= 0 then Exit(nil);
// Le constructeur sans argument s'ensemence tout seul depuis le systeme ;
// fournir une graine soi-meme ne ferait que l'affaiblir.
LRnd := TJSecureRandom.JavaClass.init;
LArr := TJavaArray<Byte>.Create(ACount);
try
LRnd.nextBytes(LArr);
Result := FromJavaBytes(LArr);
finally
LArr.Free;
end;
if Length(Result) <> ACount then
raise EPMACrypto.Create('SecureRandom returned the wrong length');
end;
{$ELSEIF DEFINED(MSWINDOWS)}
const
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
begin
if ACount <= 0 then Exit(nil);
SetLength(Result, ACount);
// Handle nul + drapeau systeme : on prend le generateur par defaut de
// Windows sans avoir a ouvrir puis fermer un fournisseur.
if BCryptGenRandom(nil, PByte(Result), ACount,
BCRYPT_USE_SYSTEM_PREFERRED_RNG) <> 0 then
raise EPMACrypto.Create('BCryptGenRandom failed');
end;
{$ELSE}
begin
raise EPMACrypto.Create('No secure random source on this platform');
end;
{$ENDIF}
function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes;
{$IF DEFINED(ANDROID)}
var
LCipher: JCipher;
LKeySpec: JSecretKeySpec;
LGcm: JGCMParameterSpec;
begin
LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey),
StringToJString('AES'));
// 128 bits de tag, comme au dechiffrement et comme WebCrypto.
LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv));
LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
LCipher.init(TJCipher.JavaClass.ENCRYPT_MODE, LKeySpec, LGcm);
// Java rend deja le tag colle a la fin - meme disposition que WebCrypto.
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(APlainText)));
end;
{$ELSEIF DEFINED(MSWINDOWS)}
var
LAlg, LKey: Pointer;
LInfo: TBCryptAuthCipherModeInfo;
LTag, LCt: TBytes;
LOutLen: Cardinal;
LMode: string;
begin
LAlg := nil;
LKey := nil;
if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then
raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed');
try
LMode := 'ChainingModeGCM';
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');
// CNG veut le tag DANS la structure, pas dans le flux : on l'y recueille,
// puis on le colle a la fin pour rendre la meme forme que WebCrypto.
SetLength(LTag, 16);
SetLength(LCt, Length(APlainText));
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);
LOutLen := 0;
if BCryptEncrypt(LKey, PByte(APlainText), Length(APlainText), @LInfo,
nil, 0, PByte(LCt), Length(LCt), LOutLen, 0) <> 0 then
raise EPMACrypto.Create('BCryptEncrypt failed');
SetLength(LCt, LOutLen);
Result := Concat(LCt, LTag);
finally
if LKey <> nil then BCryptDestroyKey(LKey);
BCryptCloseAlgorithmProvider(LAlg, 0);
end;
end;
{$ELSE}
begin
raise EPMACrypto.Create('AES-GCM is not wired for this platform');
end;
{$ENDIF}
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
/// Base64 SANS retour a la ligne. TNetEncoding.Base64 coupe a 76 caracteres,
/// comme le courrier electronique ; WebCrypto n'en met aucun. Les decodeurs
/// tolerants ignorent ces retours - `atob` retire les blancs, Node aussi - mais
/// produire exactement la meme forme que l'autre implementation retire une
/// dependance a leur tolerance.
function B64NoWrap(const ABytes: TBytes): string;
var
LEnc: TBase64Encoding;
begin
LEnc := TBase64Encoding.Create(0); // 0 = jamais de coupure
try
Result := LEnc.EncodeBytesToString(ABytes);
finally
LEnc.Free;
end;
end;
function EncryptContainer(const APlainJson, APassword: string;
AIterations: Integer): string;
var
LSalt, LKey: TBytes;
begin
if APassword = '' then
raise EPMACrypto.Create('Refusing to encrypt with an empty password');
LSalt := SecureRandomBytes(32);
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32);
if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32);
Result := EncryptContainerWithKey(APlainJson, LKey, LSalt, AIterations);
end;
function EncryptContainerWithKey(const APlainJson: string;
const AKey, ASalt: TBytes; AIterations: Integer): string;
var
LObj: TJSONObject;
LIv, LCt: TBytes;
begin
if (Length(AKey) <> 32) or (Length(ASalt) = 0) then
raise EPMACrypto.Create('Cle ou sel absent pour le rechiffrement');
// L'IV EST TIRE A CHAQUE APPEL, le sel non. Reutiliser un IV avec la meme
// cle casse GCM entierement ; reutiliser le sel ne fait que redonner la meme
// cle, ce qui est justement l'economie recherchee - le sel ralentit une
// attaque sur le mot de passe, il n'a pas a differer d'une version a l'autre.
LIv := SecureRandomBytes(12);
LCt := AesGcmEncrypt(AKey, LIv, TEncoding.UTF8.GetBytes(APlainJson));
LObj := TJSONObject.Create;
try
// Memes cles, memes valeurs que encryptExportPayload cote poste : c'est ce
// conteneur-la que les deux clients savent relire.
LObj.AddPair('format', 'pm-encrypted-export-v1');
LObj.AddPair('kdf', 'pbkdf2-sha256');
LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations));
LObj.AddPair('kdf_salt', B64NoWrap(ASalt));
LObj.AddPair('iv', B64NoWrap(LIv));
LObj.AddPair('ciphertext', B64NoWrap(LCt));
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"',
TTimeZone.Local.ToUniversalTime(Now)));
Result := LObj.ToJSON;
finally
LObj.Free;
end;
end;
function DecryptContainer(const AContainerJson, APassword: string): string;
var
LKey, LSalt: TBytes;
LIter: Integer;
begin
Result := DecryptContainerEx(AContainerJson, APassword, LKey, LSalt, LIter);
end;
function DecryptContainerEx(const AContainerJson, APassword: string;
out AKey, ASalt: TBytes; out AIterations: Integer): string;
var
LObj: TJSONObject;
LFormat, LKdf: string;
LIterations: Integer;
LSalt, LIv, LCt, LKey, LPlain: TBytes;
begin
LObj := TJSONObject.ParseJSONValue(AContainerJson) as TJSONObject;
if LObj = nil then
raise EPMACrypto.Create('Not a JSON container (truncated download?)');
try
// Checked before anything else: an HTML error page or a half-written file
// would otherwise reach the cipher and be reported as a wrong password.
LFormat := LObj.GetValue<string>('format', '');
if LFormat <> 'pm-encrypted-export-v1' then
raise EPMACrypto.CreateFmt('Unknown container format: "%s"', [LFormat]);
LKdf := LObj.GetValue<string>('kdf', '');
if LKdf <> 'pbkdf2-sha256' then
raise EPMACrypto.CreateFmt('Unsupported KDF: "%s"', [LKdf]);
LIterations := LObj.GetValue<Integer>('kdf_iterations', 600000);
LSalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('kdf_salt', ''));
LIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('iv', ''));
LCt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('ciphertext', ''));
if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then
raise EPMACrypto.Create('Container is missing salt, iv or ciphertext');
// Android's provider first - measured identical to WebCrypto on a
// non-ASCII password (so it does encode as UTF-8), and it is platform
// code rather than ours. Barely faster though: 3869 ms against 4139 ms,
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
// fallback, and is the only one outside Android.
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32);
if LKey = nil then
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
LPlain := AesGcmDecrypt(LKey, LIv, LCt);
Result := TEncoding.UTF8.GetString(LPlain);
// Rendus a l'appelant : ils lui evitent une seconde derivation s'il
// rechiffre juste apres.
AKey := LKey;
ASalt := LSalt;
AIterations := LIterations;
finally
LObj.Free;
end;
end;
end.