ae48fadb6b
Section 4 step 2. EncryptContainer produces the exact container the desktop writes: PBKDF2-SHA256, AES-256-GCM with the tag appended as WebCrypto does, a 32-byte salt and a 12-byte IV drawn per call from SecureRandom on Android and BCryptGenRandom on Windows - never Random, since a predictable salt or IV ruins the encryption that uses it and nothing in the output would show it. The check matters as much as the code. A round trip with itself only proves internal consistency, and it passed while the container carried two defects. Node - the same primitives as WebCrypto - found both by reading a container the phone had produced: - Delphi's base64 wraps at 76 characters; WebCrypto's never does. Lenient decoders accept it, but depending on their leniency is not a guarantee. - the accented literal in the check came out of the COMPILER already broken: this source has no byte-order mark, so Delphi reads it as ANSI. The defect was in the test rather than the encryption, and only the cross-check could tell those apart. The check runs at launch in DEBUG and leaves selfcheck.json in the private directory so it can be re-read from the PC. Also fixed on the way: TPath resolves to FMX.Objects.TPath - a drawing shape - because FMX.Objects comes later in the uses clause, and the error then talks about an unknown Combine rather than a name collision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
740 lines
28 KiB
ObjectPascal
740 lines
28 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;
|
|
|
|
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
|
|
LObj: TJSONObject;
|
|
LSalt, LIv, LKey, LCt: TBytes;
|
|
begin
|
|
if APassword = '' then
|
|
raise EPMACrypto.Create('Refusing to encrypt with an empty password');
|
|
|
|
// TIRES A CHAQUE APPEL, tous les deux. Reutiliser un IV avec la meme cle
|
|
// casse GCM entierement - ce n'est pas une precaution de style.
|
|
LSalt := SecureRandomBytes(32);
|
|
LIv := SecureRandomBytes(12);
|
|
|
|
LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32);
|
|
if LKey = nil then
|
|
LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32);
|
|
LCt := AesGcmEncrypt(LKey, 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(LSalt));
|
|
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
|
|
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);
|
|
finally
|
|
LObj.Free;
|
|
end;
|
|
end;
|
|
|
|
end.
|