feat(android): keep the decrypted vault locally under an Android Keystore key

Opening a container costs ~4 s and the iteration count belongs to the desktop format, so the fix is to stop repeating it: derive once per snapshot, then read a local AES file which the hardware accelerates. The key is generated inside the Keystore and never leaves it - the file alone is useless, even rooted.

ETag and vault are encrypted together on purpose: an ETag outliving an unreadable vault would make the server answer 304 with nothing to show. A failed read wipes the cache rather than guessing.

Also normalises every Delphi source to pure ASCII: Delphi reads a BOM-less file as ANSI, so accented comments came back as mojibake, and each PowerShell rewrite added another layer.
This commit is contained in:
r-zakarya
2026-08-15 00:37:59 +01:00
parent 3c1525e46f
commit 5fdb7f914e
5 changed files with 422 additions and 48 deletions
+12 -12
View File
@@ -14,14 +14,14 @@ unit PMA.Crypto;
- 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 —
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
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
@@ -41,13 +41,13 @@ type
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
AIterations, AKeyLenBytes: Integer): TBytes;
/// Same derivation, delegated to Android's SecretKeyFactory — natively
/// 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.
/// 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;
@@ -57,11 +57,11 @@ function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
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 —
/// 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
/// 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;
@@ -101,7 +101,7 @@ function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
//
// 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
// 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;
@@ -139,7 +139,7 @@ end;
{$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
// 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;
@@ -193,7 +193,7 @@ type
// 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
// (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}']
@@ -280,7 +280,7 @@ var
begin
Result := nil;
try
// The password goes in as chars — the whole point of the doubt. Delphi
// 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.
@@ -328,7 +328,7 @@ begin
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.
// 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.
@@ -376,7 +376,7 @@ begin
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
// 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