f245cdcc38
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>
779 lines
30 KiB
ObjectPascal
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.
|