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.
This commit is contained in:
r-zakarya
2026-08-14 18:33:56 +01:00
parent 386bf74a2a
commit c607e26c4a
3 changed files with 109 additions and 0 deletions
+84
View File
@@ -41,6 +41,18 @@ type
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// Same derivation, delegated to Android's SecretKeyFactory — natively
/// implemented, so roughly an order of magnitude faster than the Pascal loop.
///
/// 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.
@@ -163,6 +175,42 @@ type
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;
@@ -196,6 +244,42 @@ begin
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