4cc79a5216
generateSecret takes a KeySpec; passing a JObject made the bridge emit a signature no method matches, and the call failed at runtime. Swallowed as 'unavailable', which hid my own mistake for a full test round - the failure now carries its exception message. Measured on device: the Pascal PBKDF2 takes 6651 ms, unusable per unlock. AES-GCM through JNI works, plaintext and hash match the vector, so the container format is validated end to end.
386 lines
14 KiB
ObjectPascal
386 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;
|
|
|
|
/// 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;
|
|
|
|
implementation
|
|
|
|
{$IF DEFINED(ANDROID)}
|
|
uses
|
|
Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
|
|
{$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 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(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;
|
|
{$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.
|