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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user