Files
Password-Manager/android-client/PMA.Crypto.pas
T
r-zakarya c607e26c4a feat(android): measure the native PBKDF2 against the vector instead of guessing
The Pascal loop takes 1736 ms on a desktop CPU, so several seconds on a phone - too slow for every unlock. SecretKeyFactory is native and roughly an order of magnitude faster, but PBEKeySpec takes a char[] and the provider picks the encoding, which was the reason for avoiding it.

The non-ASCII vector settles it on a real device: same key means the provider uses UTF-8 and we adopt it; a different key means the Pascal implementation stays. DecryptContainer keeps using the Pascal one until that measurement says otherwise.
2026-08-14 18:33:56 +01:00

356 lines
14 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.
- 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;
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 Android's SecretKeyFactory — natively
/// implemented, so roughly an order of magnitude faster than the Pascal loop.
///
/// 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;
/// 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;
implementation
{$IF DEFINED(ANDROID)}
uses
Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
{$ENDIF}
// ---------------------------------------------------------------------------
// 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;
JPBEKeySpec = interface;
JSecretKey = interface;
JSecretKeyFactory = interface;
JPBEKeySpecClass = interface(JObjectClass)
['{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(JObject)
['{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: JObject): JSecretKey; cdecl;
end;
TJSecretKeyFactory = class(TJavaGenericImport<JSecretKeyFactoryClass, JSecretKeyFactory>) end;
JCipherClass = interface(JObjectClass)
['{E081D312-94A5-B6C7-02FD-1E8F90011223}']
function _GetDECRYPT_MODE: Integer; cdecl;
function getInstance(transformation: JString): JCipher; cdecl;
property DECRYPT_MODE: Integer read _GetDECRYPT_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;
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(TJObject.Wrap((LSpec as ILocalObject).GetObjectID)).getEncoded);
except
// Provider missing or algorithm unavailable: the caller falls back to the
// Pascal implementation, which always works.
Result := nil;
end;
end;
{$ELSE}
begin
Result := nil; // no platform provider outside Android
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
raise EPMACrypto.Create('Decryption failed — wrong password or corrupt file');
end;
end;
{$ELSE}
begin
// Windows/macOS build of the same units: no platform AES-GCM available here.
// The desktop app already has its own crypto; this unit targets Android.
raise EPMACrypto.Create('AES-GCM is only wired for Android in this unit');
end;
{$ENDIF}
// ---------------------------------------------------------------------------
// Container
// ---------------------------------------------------------------------------
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');
LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32);
LPlain := AesGcmDecrypt(LKey, LIv, LCt);
Result := TEncoding.UTF8.GetString(LPlain);
finally
LObj.Free;
end;
end;
end.