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
|
- AES-GCM goes through JNI to javax.crypto. One call, no loop, and the
|
||||||
platform implementation is hardware-accelerated. Java's Cipher expects 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.
|
the buffers are byte-identical, nothing to reshape.
|
||||||
|
|
||||||
- PBKDF2 is implemented HERE rather than via SecretKeyFactory, because
|
- PBKDF2 is implemented HERE rather than via SecretKeyFactory, because
|
||||||
javax.crypto.spec.PBEKeySpec takes a char[] and the provider decides how
|
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
|
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,
|
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
|
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
|
construction. The cost is 600k HMAC-SHA256 rounds in Pascal; measure it on
|
||||||
a real device (see DeriveKeyPBKDF2's note) before considering the JNI
|
a real device (see DeriveKeyPBKDF2's note) before considering the JNI
|
||||||
@@ -41,13 +41,13 @@ type
|
|||||||
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
|
function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes;
|
||||||
AIterations, AKeyLenBytes: Integer): 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.
|
/// implemented, so roughly an order of magnitude faster than the Pascal loop.
|
||||||
///
|
///
|
||||||
/// NOT used by DecryptContainer, and deliberately so: PBEKeySpec takes a
|
/// NOT used by DecryptContainer, and deliberately so: PBEKeySpec takes a
|
||||||
/// char[] and the provider decides how those chars become bytes. If Android
|
/// 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
|
/// 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
|
/// 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.
|
/// it only if they match there. Returns nil on any failure.
|
||||||
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
|
function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
|
||||||
@@ -57,11 +57,11 @@ function DeriveKeyPBKDF2Native(const APassword: string; const ASalt: TBytes;
|
|||||||
function LastNativeError: string;
|
function LastNativeError: string;
|
||||||
|
|
||||||
/// AES-256-GCM decrypt. ACipherText carries the 16-byte tag at the end, as
|
/// 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.
|
/// which is what a wrong password looks like.
|
||||||
function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes;
|
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
|
/// truncated download or an HTML error page fails with a clear message rather
|
||||||
/// than as "wrong password".
|
/// than as "wrong password".
|
||||||
function DecryptContainer(const AContainerJson, APassword: string): string;
|
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
|
// 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
|
// 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.
|
// iteration count, which is shared with the desktop and defines the format.
|
||||||
var
|
var
|
||||||
LPwd, LBlock, LU, LT: TBytes;
|
LPwd, LBlock, LU, LT: TBytes;
|
||||||
@@ -139,7 +139,7 @@ end;
|
|||||||
{$IF DEFINED(ANDROID)}
|
{$IF DEFINED(ANDROID)}
|
||||||
type
|
type
|
||||||
// Minimal JNI imports. Delphi ships no headers for javax.crypto, and the
|
// 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
|
// real Java types (Key, AlgorithmParameterSpec) rather than JObject, which
|
||||||
// would generate a signature no method matches.
|
// would generate a signature no method matches.
|
||||||
JKey = interface;
|
JKey = interface;
|
||||||
@@ -193,7 +193,7 @@ type
|
|||||||
|
|
||||||
// generateSecret takes a KeySpec, not an Object. Declaring the real
|
// generateSecret takes a KeySpec, not an Object. Declaring the real
|
||||||
// interface is what makes the bridge emit
|
// 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.
|
// emitted a signature no method matches, and the call failed at runtime.
|
||||||
JKeySpecClass = interface(IJavaClass)
|
JKeySpecClass = interface(IJavaClass)
|
||||||
['{0A1B2C3D-4E5F-6071-8293-A4B5C6D7E8F9}']
|
['{0A1B2C3D-4E5F-6071-8293-A4B5C6D7E8F9}']
|
||||||
@@ -280,7 +280,7 @@ var
|
|||||||
begin
|
begin
|
||||||
Result := nil;
|
Result := nil;
|
||||||
try
|
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
|
// 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
|
// it apply whatever encoding it wants. That is exactly what the test
|
||||||
// measures against the vector.
|
// measures against the vector.
|
||||||
@@ -328,7 +328,7 @@ begin
|
|||||||
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText)));
|
Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText)));
|
||||||
except
|
except
|
||||||
// AEADBadTagException surfaces here: wrong password, or altered bytes.
|
// 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
|
on E: Exception do
|
||||||
// Message en pur ASCII : un tiret cadratin dans une source lue en ANSI
|
// Message en pur ASCII : un tiret cadratin dans une source lue en ANSI
|
||||||
// par Delphi ressort en mojibake sur l'appareil.
|
// 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
|
if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then
|
||||||
raise EPMACrypto.Create('Container is missing salt, iv or ciphertext');
|
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
|
// 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,
|
// code rather than ours. Barely faster though: 3869 ms against 4139 ms,
|
||||||
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
|
// PBKDF2 gets no hardware acceleration. The Pascal path stays as the
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
unit PMA.Store;
|
||||||
|
{
|
||||||
|
Coffre garde localement, chiffre sous une cle du Android Keystore.
|
||||||
|
|
||||||
|
POURQUOI : ouvrir un conteneur coute ~4 s (PBKDF2 600k, incompressible - le
|
||||||
|
nombre d'iterations appartient au format ecrit par le poste Windows). Payer
|
||||||
|
ce prix a chaque ouverture de l'application serait absurde alors que le
|
||||||
|
contenu n'a pas change. On ne le paie donc qu'a l'arrivee d'un instantane
|
||||||
|
NOUVEAU, et on relit ensuite un fichier local chiffre en AES - accelere
|
||||||
|
materiellement, donc instantane.
|
||||||
|
|
||||||
|
LA CLE NE QUITTE JAMAIS LE MATERIEL. Elle est generee dans le Keystore et
|
||||||
|
n'en sort pas : on envoie des donnees a chiffrer, on recoit le resultat. Un
|
||||||
|
vol du fichier seul est donc inexploitable, meme root - il faut la puce.
|
||||||
|
|
||||||
|
CE QUE CE FICHIER CONTIENT : tous les mots de passe en clair, une fois
|
||||||
|
dechiffre. D'ou trois regles tenues ici :
|
||||||
|
- jamais hors du repertoire prive de l'application ;
|
||||||
|
- jamais de copie en clair sur le disque, meme temporaire ;
|
||||||
|
- la sauvegarde automatique Android doit etre desactivee pour l'app
|
||||||
|
(android:allowBackup="false"), sinon Google en recupere une copie.
|
||||||
|
|
||||||
|
PAS ENCORE FAIT : exiger la biometrie a chaque lecture
|
||||||
|
(setUserAuthenticationRequired). La cle est deja liee au materiel ; la
|
||||||
|
biometrie ajouterait une barriere si le telephone est deverrouille et qu'un
|
||||||
|
tiers ouvre l'application. A faire dans un second temps, avec BiometricPrompt.
|
||||||
|
}
|
||||||
|
|
||||||
|
interface
|
||||||
|
|
||||||
|
uses
|
||||||
|
System.SysUtils, System.Classes, System.JSON, System.IOUtils;
|
||||||
|
|
||||||
|
type
|
||||||
|
EPMAStore = class(Exception);
|
||||||
|
|
||||||
|
/// Le coffre local existe-t-il ?
|
||||||
|
function HasVault: Boolean;
|
||||||
|
|
||||||
|
/// Ecrit le coffre et l'ETag qui l'accompagne, chiffres ensemble.
|
||||||
|
procedure SaveVault(const APlainJson, AETag: string);
|
||||||
|
|
||||||
|
/// Relit le coffre. False si absent ou illisible (cle perdue apres une
|
||||||
|
/// reinstallation, fichier altere) - dans ce cas il faut retelecharger.
|
||||||
|
function LoadVault(out APlainJson, AETag: string): Boolean;
|
||||||
|
|
||||||
|
/// Efface le coffre local. A appeler au moindre doute : il se retelecharge.
|
||||||
|
procedure ClearVault;
|
||||||
|
|
||||||
|
/// Chemin du fichier, pour le journal et les tests.
|
||||||
|
function VaultPath: string;
|
||||||
|
|
||||||
|
implementation
|
||||||
|
|
||||||
|
{$IF DEFINED(ANDROID)}
|
||||||
|
uses
|
||||||
|
Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers;
|
||||||
|
{$ENDIF}
|
||||||
|
|
||||||
|
const
|
||||||
|
VAULT_FILE = 'vault.bin';
|
||||||
|
KEY_ALIAS = 'pm_vault_key';
|
||||||
|
// Constantes d'android.security.keystore.KeyProperties, reprises en dur
|
||||||
|
// plutot qu'importees : elles sont figees par la plateforme, et cela evite
|
||||||
|
// une declaration JNI de plus.
|
||||||
|
PURPOSE_ENCRYPT = 1;
|
||||||
|
PURPOSE_DECRYPT = 2;
|
||||||
|
IV_LEN = 12; // GCM : 12 octets, la longueur recommandee
|
||||||
|
|
||||||
|
function VaultPath: string;
|
||||||
|
begin
|
||||||
|
// GetHomePath sur Android = /data/data/<paquet>/files, prive a l'app.
|
||||||
|
Result := TPath.Combine(TPath.GetHomePath, VAULT_FILE);
|
||||||
|
end;
|
||||||
|
|
||||||
|
function HasVault: Boolean;
|
||||||
|
begin
|
||||||
|
Result := TFile.Exists(VaultPath);
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure ClearVault;
|
||||||
|
begin
|
||||||
|
if TFile.Exists(VaultPath) then
|
||||||
|
TFile.Delete(VaultPath);
|
||||||
|
end;
|
||||||
|
|
||||||
|
{$IF DEFINED(ANDROID)}
|
||||||
|
type
|
||||||
|
JKeyStoreX = interface;
|
||||||
|
JSecretKeyX = interface;
|
||||||
|
JKeyGenerator = interface;
|
||||||
|
JKeyGenParameterSpec = interface;
|
||||||
|
JKeyGenParameterSpec_Builder = interface;
|
||||||
|
JCipherX = interface;
|
||||||
|
JGCMParameterSpecX = interface;
|
||||||
|
JAlgorithmParameterSpecX = interface;
|
||||||
|
JKeyX = interface;
|
||||||
|
|
||||||
|
JKeyXClass = interface(IJavaClass)
|
||||||
|
['{2C1A0B3D-4E5F-6A7B-8C9D-0E1F2A3B4C5D}']
|
||||||
|
end;
|
||||||
|
[JavaSignature('java/security/Key')]
|
||||||
|
JKeyX = interface(IJavaInstance)
|
||||||
|
['{3D2B1C4E-5F60-7B8C-9DAE-1F2A3B4C5D6E}']
|
||||||
|
end;
|
||||||
|
TJKeyX = class(TJavaGenericImport<JKeyXClass, JKeyX>) end;
|
||||||
|
|
||||||
|
JSecretKeyXClass = interface(JKeyXClass)
|
||||||
|
['{4E3C2D5F-6071-8C9D-AEBF-2A3B4C5D6E7F}']
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/SecretKey')]
|
||||||
|
JSecretKeyX = interface(JKeyX)
|
||||||
|
['{5F4D3E60-7182-9DAE-BFC0-3B4C5D6E7F80}']
|
||||||
|
end;
|
||||||
|
TJSecretKeyX = class(TJavaGenericImport<JSecretKeyXClass, JSecretKeyX>) end;
|
||||||
|
|
||||||
|
JAlgorithmParameterSpecXClass = interface(IJavaClass)
|
||||||
|
['{605E4F71-8293-AEBF-C0D1-4C5D6E7F8091}']
|
||||||
|
end;
|
||||||
|
[JavaSignature('java/security/spec/AlgorithmParameterSpec')]
|
||||||
|
JAlgorithmParameterSpecX = interface(IJavaInstance)
|
||||||
|
['{716F5082-93A4-BFC0-D1E2-5D6E7F8091A2}']
|
||||||
|
end;
|
||||||
|
TJAlgorithmParameterSpecX = class(TJavaGenericImport<JAlgorithmParameterSpecXClass, JAlgorithmParameterSpecX>) end;
|
||||||
|
|
||||||
|
JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass)
|
||||||
|
['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}']
|
||||||
|
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpecX; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
|
||||||
|
JGCMParameterSpecX = interface(JAlgorithmParameterSpecX)
|
||||||
|
['{938172A4-B5C6-D1E2-F304-7F8091A2B3C4}']
|
||||||
|
end;
|
||||||
|
TJGCMParameterSpecX = class(TJavaGenericImport<JGCMParameterSpecXClass, JGCMParameterSpecX>) end;
|
||||||
|
|
||||||
|
JKeyStoreXClass = interface(JObjectClass)
|
||||||
|
['{A49283B5-C6D7-E2F3-0415-8091A2B3C4D5}']
|
||||||
|
function getInstance(&type: JString): JKeyStoreX; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('java/security/KeyStore')]
|
||||||
|
JKeyStoreX = interface(JObject)
|
||||||
|
['{B5A394C6-D7E8-F304-1526-91A2B3C4D5E6}']
|
||||||
|
procedure load(param: JObject); cdecl;
|
||||||
|
function getKey(alias: JString; password: TJavaArray<Char>): JKeyX; cdecl;
|
||||||
|
function containsAlias(alias: JString): Boolean; cdecl;
|
||||||
|
end;
|
||||||
|
TJKeyStoreX = class(TJavaGenericImport<JKeyStoreXClass, JKeyStoreX>) end;
|
||||||
|
|
||||||
|
JKeyGenParameterSpec_BuilderClass = interface(JObjectClass)
|
||||||
|
['{C6B4A5D7-E8F9-0415-2637-A2B3C4D5E6F7}']
|
||||||
|
function init(keystoreAlias: JString; purposes: Integer): JKeyGenParameterSpec_Builder; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('android/security/keystore/KeyGenParameterSpec$Builder')]
|
||||||
|
JKeyGenParameterSpec_Builder = interface(JObject)
|
||||||
|
['{D7C5B6E8-F90A-1526-3748-B3C4D5E6F708}']
|
||||||
|
function setBlockModes(blockModes: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
|
||||||
|
function setEncryptionPaddings(paddings: TJavaObjectArray<JString>): JKeyGenParameterSpec_Builder; cdecl;
|
||||||
|
function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl;
|
||||||
|
function build: JKeyGenParameterSpec; cdecl;
|
||||||
|
end;
|
||||||
|
TJKeyGenParameterSpec_Builder = class(TJavaGenericImport<JKeyGenParameterSpec_BuilderClass, JKeyGenParameterSpec_Builder>) end;
|
||||||
|
|
||||||
|
JKeyGenParameterSpecClass = interface(JAlgorithmParameterSpecXClass)
|
||||||
|
['{E8D6C7F9-0A1B-2637-4859-C4D5E6F70819}']
|
||||||
|
end;
|
||||||
|
[JavaSignature('android/security/keystore/KeyGenParameterSpec')]
|
||||||
|
JKeyGenParameterSpec = interface(JAlgorithmParameterSpecX)
|
||||||
|
['{F9E7D80A-1B2C-3748-5960-D5E6F7081920}']
|
||||||
|
end;
|
||||||
|
TJKeyGenParameterSpec = class(TJavaGenericImport<JKeyGenParameterSpecClass, JKeyGenParameterSpec>) end;
|
||||||
|
|
||||||
|
JKeyGeneratorClass = interface(JObjectClass)
|
||||||
|
['{0AF8E91B-2C3D-4859-6A71-E6F708192031}']
|
||||||
|
function getInstance(algorithm: JString; provider: JString): JKeyGenerator; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/KeyGenerator')]
|
||||||
|
JKeyGenerator = interface(JObject)
|
||||||
|
['{1B09FA2C-3D4E-5960-7B82-F70819203142}']
|
||||||
|
procedure init(params: JAlgorithmParameterSpecX); cdecl;
|
||||||
|
function generateKey: JSecretKeyX; cdecl;
|
||||||
|
end;
|
||||||
|
TJKeyGenerator = class(TJavaGenericImport<JKeyGeneratorClass, JKeyGenerator>) end;
|
||||||
|
|
||||||
|
JCipherXClass = interface(JObjectClass)
|
||||||
|
['{2C1A0B3D-4E5F-6A71-8C93-081920314253}']
|
||||||
|
function _GetENCRYPT_MODE: Integer; cdecl;
|
||||||
|
function _GetDECRYPT_MODE: Integer; cdecl;
|
||||||
|
function getInstance(transformation: JString): JCipherX; cdecl;
|
||||||
|
property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE;
|
||||||
|
property DECRYPT_MODE: Integer read _GetDECRYPT_MODE;
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/Cipher')]
|
||||||
|
JCipherX = interface(JObject)
|
||||||
|
['{3D2B1C4E-5F60-7B82-9DA4-192031425364}']
|
||||||
|
procedure init(opmode: Integer; key: JKeyX); cdecl;
|
||||||
|
procedure init(opmode: Integer; key: JKeyX; params: JAlgorithmParameterSpecX); cdecl; overload;
|
||||||
|
function doFinal(input: TJavaArray<Byte>): TJavaArray<Byte>; cdecl;
|
||||||
|
function getIV: TJavaArray<Byte>; cdecl;
|
||||||
|
end;
|
||||||
|
TJCipherX = class(TJavaGenericImport<JCipherXClass, JCipherX>) end;
|
||||||
|
|
||||||
|
function ToJava(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 FromJava(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;
|
||||||
|
|
||||||
|
/// La cle du Keystore, creee a la premiere utilisation. Elle n'est jamais
|
||||||
|
/// lue : on obtient une poignee vers un objet que seul le materiel peut
|
||||||
|
/// utiliser.
|
||||||
|
function EnsureKey: JKeyX;
|
||||||
|
var
|
||||||
|
LStore: JKeyStoreX;
|
||||||
|
LGen: JKeyGenerator;
|
||||||
|
LBuilder: JKeyGenParameterSpec_Builder;
|
||||||
|
LModes, LPaddings: TJavaObjectArray<JString>;
|
||||||
|
begin
|
||||||
|
LStore := TJKeyStoreX.JavaClass.getInstance(StringToJString('AndroidKeyStore'));
|
||||||
|
LStore.load(nil); // le magasin Android ne prend pas de flux
|
||||||
|
|
||||||
|
if LStore.containsAlias(StringToJString(KEY_ALIAS)) then
|
||||||
|
Exit(LStore.getKey(StringToJString(KEY_ALIAS), nil));
|
||||||
|
|
||||||
|
LModes := TJavaObjectArray<JString>.Create(1);
|
||||||
|
LModes.Items[0] := StringToJString('GCM');
|
||||||
|
LPaddings := TJavaObjectArray<JString>.Create(1);
|
||||||
|
LPaddings.Items[0] := StringToJString('NoPadding');
|
||||||
|
|
||||||
|
LBuilder := TJKeyGenParameterSpec_Builder.JavaClass.init(
|
||||||
|
StringToJString(KEY_ALIAS), PURPOSE_ENCRYPT or PURPOSE_DECRYPT);
|
||||||
|
LBuilder := LBuilder.setBlockModes(LModes);
|
||||||
|
LBuilder := LBuilder.setEncryptionPaddings(LPaddings);
|
||||||
|
LBuilder := LBuilder.setKeySize(256);
|
||||||
|
|
||||||
|
LGen := TJKeyGenerator.JavaClass.getInstance(
|
||||||
|
StringToJString('AES'), StringToJString('AndroidKeyStore'));
|
||||||
|
LGen.init(LBuilder.build);
|
||||||
|
Result := LGen.generateKey; // stockee sous l'alias, pas retournee en clair
|
||||||
|
end;
|
||||||
|
{$ENDIF}
|
||||||
|
|
||||||
|
procedure SaveVault(const APlainJson, AETag: string);
|
||||||
|
{$IF DEFINED(ANDROID)}
|
||||||
|
var
|
||||||
|
LWrap: TJSONObject;
|
||||||
|
LPlain, LIv, LCt, LOut: TBytes;
|
||||||
|
LCipher: JCipherX;
|
||||||
|
begin
|
||||||
|
// ETag et coffre chiffres ENSEMBLE : ils doivent rester coherents. Un ETag
|
||||||
|
// qui survivrait a un coffre illisible ferait repondre 304 au serveur alors
|
||||||
|
// que l'application n'a rien a afficher.
|
||||||
|
LWrap := TJSONObject.Create;
|
||||||
|
try
|
||||||
|
LWrap.AddPair('etag', AETag);
|
||||||
|
LWrap.AddPair('vault', APlainJson);
|
||||||
|
LPlain := TEncoding.UTF8.GetBytes(LWrap.ToJSON);
|
||||||
|
finally
|
||||||
|
LWrap.Free;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
||||||
|
LCipher.init(TJCipherX.JavaClass.ENCRYPT_MODE, EnsureKey);
|
||||||
|
// L'IV est GENERE par le Keystore, jamais choisi par nous : le reutiliser
|
||||||
|
// avec la meme cle romprait GCM.
|
||||||
|
LIv := FromJava(LCipher.getIV);
|
||||||
|
LCt := FromJava(LCipher.doFinal(ToJava(LPlain)));
|
||||||
|
|
||||||
|
SetLength(LOut, 1 + Length(LIv) + Length(LCt));
|
||||||
|
LOut[0] := 1; // version de format
|
||||||
|
Move(LIv[0], LOut[1], Length(LIv));
|
||||||
|
Move(LCt[0], LOut[1 + Length(LIv)], Length(LCt));
|
||||||
|
TFile.WriteAllBytes(VaultPath, LOut);
|
||||||
|
end;
|
||||||
|
{$ELSE}
|
||||||
|
begin
|
||||||
|
raise EPMAStore.Create('Le stockage Keystore est propre a Android');
|
||||||
|
end;
|
||||||
|
{$ENDIF}
|
||||||
|
|
||||||
|
function LoadVault(out APlainJson, AETag: string): Boolean;
|
||||||
|
{$IF DEFINED(ANDROID)}
|
||||||
|
var
|
||||||
|
LRaw, LIv, LCt, LPlain: TBytes;
|
||||||
|
LCipher: JCipherX;
|
||||||
|
LWrap: TJSONObject;
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
APlainJson := '';
|
||||||
|
AETag := '';
|
||||||
|
if not TFile.Exists(VaultPath) then Exit;
|
||||||
|
|
||||||
|
try
|
||||||
|
LRaw := TFile.ReadAllBytes(VaultPath);
|
||||||
|
if (Length(LRaw) < 1 + IV_LEN + 16) or (LRaw[0] <> 1) then Exit;
|
||||||
|
|
||||||
|
SetLength(LIv, IV_LEN);
|
||||||
|
Move(LRaw[1], LIv[0], IV_LEN);
|
||||||
|
SetLength(LCt, Length(LRaw) - 1 - IV_LEN);
|
||||||
|
Move(LRaw[1 + IV_LEN], LCt[0], Length(LCt));
|
||||||
|
|
||||||
|
LCipher := TJCipherX.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding'));
|
||||||
|
LCipher.init(TJCipherX.JavaClass.DECRYPT_MODE, EnsureKey,
|
||||||
|
TJGCMParameterSpecX.JavaClass.init(128, ToJava(LIv)));
|
||||||
|
LPlain := FromJava(LCipher.doFinal(ToJava(LCt)));
|
||||||
|
|
||||||
|
LWrap := TJSONObject.ParseJSONValue(TEncoding.UTF8.GetString(LPlain)) as TJSONObject;
|
||||||
|
if LWrap = nil then Exit;
|
||||||
|
try
|
||||||
|
AETag := LWrap.GetValue<string>('etag', '');
|
||||||
|
APlainJson := LWrap.GetValue<string>('vault', '');
|
||||||
|
Result := APlainJson <> '';
|
||||||
|
finally
|
||||||
|
LWrap.Free;
|
||||||
|
end;
|
||||||
|
except
|
||||||
|
// Cle disparue (reinstallation, effacement du Keystore) ou fichier
|
||||||
|
// altere : on ne tente rien, l'appelant retelechargera.
|
||||||
|
on E: Exception do
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
ClearVault;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
{$ELSE}
|
||||||
|
begin
|
||||||
|
Result := False;
|
||||||
|
APlainJson := '';
|
||||||
|
AETag := '';
|
||||||
|
end;
|
||||||
|
{$ENDIF}
|
||||||
|
|
||||||
|
end.
|
||||||
@@ -3,7 +3,7 @@ unit PMA.SyncClient;
|
|||||||
Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php).
|
Lecture de l'instantane chiffre depuis l'endpoint PHP (server-php/sync.php).
|
||||||
|
|
||||||
Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie.
|
Ne dechiffre rien : il ramene des octets opaques et l'ETag qui les identifie.
|
||||||
Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes — d'ou
|
Le dechiffrement est le travail de PMA.Crypto, et il coute 4 secondes - d'ou
|
||||||
l'importance de l'ETag, qui evite de le refaire quand rien n'a change.
|
l'importance de l'ETag, qui evite de le refaire quand rien n'a change.
|
||||||
|
|
||||||
Le serveur repond 404 aussi bien pour un jeton invalide que pour un
|
Le serveur repond 404 aussi bien pour un jeton invalide que pour un
|
||||||
|
|||||||
+66
-34
@@ -28,7 +28,7 @@ const
|
|||||||
//
|
//
|
||||||
// Le port differe selon la plateforme : sur Windows on attaque Apache
|
// Le port differe selon la plateforme : sur Windows on attaque Apache
|
||||||
// directement (80), sur Android on passe par "adb reverse tcp:8080 tcp:80",
|
// directement (80), sur Android on passe par "adb reverse tcp:8080 tcp:80",
|
||||||
// qui fait voir le PC comme 127.0.0.1 — ce qui satisfait au passage
|
// qui fait voir le PC comme 127.0.0.1 - ce qui satisfait au passage
|
||||||
// l'exemption HTTPS de l'endpoint, reservee a la boucle locale.
|
// l'exemption HTTPS de l'endpoint, reservee a la boucle locale.
|
||||||
{$IF DEFINED(ANDROID)}
|
{$IF DEFINED(ANDROID)}
|
||||||
TEST_URL = 'http://127.0.0.1:8080/server-php/sync.php';
|
TEST_URL = 'http://127.0.0.1:8080/server-php/sync.php';
|
||||||
@@ -36,7 +36,7 @@ const
|
|||||||
TEST_URL = 'http://127.0.0.1/server-php/sync.php';
|
TEST_URL = 'http://127.0.0.1/server-php/sync.php';
|
||||||
{$ENDIF}
|
{$ENDIF}
|
||||||
// Jeton d'un serveur de test local. Il n'authentifie qu'un appareil et ne
|
// Jeton d'un serveur de test local. Il n'authentifie qu'un appareil et ne
|
||||||
// donne acces qu'a du chiffre — mais a remplacer avant toute mise en ligne.
|
// donne acces qu'a du chiffre - mais a remplacer avant toute mise en ligne.
|
||||||
TEST_TOKEN = '8765e5bf426039e9888f44bfbab27d489d12b7dbaaa54c8c695ae6af8b2a820e';
|
TEST_TOKEN = '8765e5bf426039e9888f44bfbab27d489d12b7dbaaa54c8c695ae6af8b2a820e';
|
||||||
|
|
||||||
type
|
type
|
||||||
@@ -53,6 +53,7 @@ type
|
|||||||
FETag: string;
|
FETag: string;
|
||||||
procedure RunSelfTest(Sender: TObject);
|
procedure RunSelfTest(Sender: TObject);
|
||||||
procedure RunFetch(Sender: TObject);
|
procedure RunFetch(Sender: TObject);
|
||||||
|
procedure CountEntries(const APlainJson: string);
|
||||||
procedure Log(const AMsg: string);
|
procedure Log(const AMsg: string);
|
||||||
public
|
public
|
||||||
constructor Create(AOwner: TComponent); override;
|
constructor Create(AOwner: TComponent); override;
|
||||||
@@ -64,22 +65,22 @@ var
|
|||||||
implementation
|
implementation
|
||||||
|
|
||||||
uses
|
uses
|
||||||
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient;
|
System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store;
|
||||||
|
|
||||||
{$R *.fmx}
|
{$R *.fmx}
|
||||||
|
|
||||||
constructor TForm8.Create(AOwner: TComponent);
|
constructor TForm8.Create(AOwner: TComponent);
|
||||||
begin
|
begin
|
||||||
inherited;
|
inherited;
|
||||||
Caption := 'PM — crypto self-test';
|
Caption := 'PM - crypto self-test';
|
||||||
|
|
||||||
// ORDRE DE CREATION INVERSE : en FMX, des controles alignes en haut
|
// ORDRE DE CREATION INVERSE : en FMX, des controles alignes en haut
|
||||||
// s'empilent a rebours de leur creation — le dernier cree se retrouve tout
|
// s'empilent a rebours de leur creation - le dernier cree se retrouve tout
|
||||||
// en haut. On cree donc du bas vers le haut pour obtenir URL, jeton, mot de
|
// en haut. On cree donc du bas vers le haut pour obtenir URL, jeton, mot de
|
||||||
// passe, puis les deux boutons.
|
// passe, puis les deux boutons.
|
||||||
//
|
//
|
||||||
// Rien n'est persiste : c'est un banc d'essai, et le mot de passe de sync
|
// Rien n'est persiste : c'est un banc d'essai, et le mot de passe de sync
|
||||||
// ouvre le coffre entier — il n'a pas a trainer sur le disque tant que le
|
// ouvre le coffre entier - il n'a pas a trainer sur le disque tant que le
|
||||||
// stockage Keystore n'est pas ecrit.
|
// stockage Keystore n'est pas ecrit.
|
||||||
FBtn := TButton.Create(Self);
|
FBtn := TButton.Create(Self);
|
||||||
FBtn.Parent := Self;
|
FBtn.Parent := Self;
|
||||||
@@ -137,6 +138,26 @@ begin
|
|||||||
end);
|
end);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
procedure TForm8.CountEntries(const APlainJson: string);
|
||||||
|
var
|
||||||
|
LJson: TJSONObject;
|
||||||
|
LArr: TJSONArray;
|
||||||
|
LLive, LTrash, I: Integer;
|
||||||
|
begin
|
||||||
|
LJson := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
|
||||||
|
if LJson = nil then Exit;
|
||||||
|
try
|
||||||
|
if not LJson.TryGetValue<TJSONArray>('entries', LArr) then Exit;
|
||||||
|
LLive := 0; LTrash := 0;
|
||||||
|
for I := 0 to LArr.Count - 1 do
|
||||||
|
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('deleted', 0) = 1
|
||||||
|
then Inc(LTrash) else Inc(LLive);
|
||||||
|
Log(' ' + LLive.ToString + ' entrees, ' + LTrash.ToString + ' en corbeille');
|
||||||
|
finally
|
||||||
|
LJson.Free;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
procedure TForm8.RunFetch(Sender: TObject);
|
procedure TForm8.RunFetch(Sender: TObject);
|
||||||
var
|
var
|
||||||
LUrl, LTok, LPwd: string;
|
LUrl, LTok, LPwd: string;
|
||||||
@@ -154,12 +175,20 @@ begin
|
|||||||
var
|
var
|
||||||
LClient: TSyncClient;
|
LClient: TSyncClient;
|
||||||
LRes: TSyncResult;
|
LRes: TSyncResult;
|
||||||
LPlain: string;
|
LPlain, LCached, LCachedETag: string;
|
||||||
LSw: TStopwatch;
|
LSw: TStopwatch;
|
||||||
LJson: TJSONObject;
|
|
||||||
LArr: TJSONArray;
|
|
||||||
LLive, LTrash, I: Integer;
|
|
||||||
begin
|
begin
|
||||||
|
// Le coffre local d'abord : s'il est la, on connait deja l'ETag et on
|
||||||
|
// evite les 4 secondes de derivation quand rien n'a change.
|
||||||
|
if LoadVault(LCached, LCachedETag) then
|
||||||
|
begin
|
||||||
|
FETag := LCachedETag;
|
||||||
|
Log('Coffre local : ' + Length(LCached).ToString +
|
||||||
|
' caracteres, ETag ' + Copy(LCachedETag, 1, 16) + '...');
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Log('Pas de coffre local - premier telechargement');
|
||||||
|
|
||||||
LClient := TSyncClient.Create(LUrl, LTok);
|
LClient := TSyncClient.Create(LUrl, LTok);
|
||||||
// try/finally et NON un simple enchainement : les Exit des branches
|
// try/finally et NON un simple enchainement : les Exit des branches
|
||||||
// d'erreur ci-dessous sautent la fin de la procedure, ce qui laissait le
|
// d'erreur ci-dessous sautent la fin de la procedure, ce qui laissait le
|
||||||
@@ -177,7 +206,17 @@ begin
|
|||||||
case LRes.Status of
|
case LRes.Status of
|
||||||
ssNotModified:
|
ssNotModified:
|
||||||
begin
|
begin
|
||||||
Log(' ==> 304 : inchange, rien a re-dechiffrer');
|
// C'est ICI que le cache paie : pas de telechargement, pas de
|
||||||
|
// derivation, le coffre vient du fichier local.
|
||||||
|
Log(' ==> 304 : inchange');
|
||||||
|
if LCached <> '' then
|
||||||
|
begin
|
||||||
|
LPlain := LCached;
|
||||||
|
Log(' coffre servi depuis le cache local, 0 ms de derivation');
|
||||||
|
CountEntries(LPlain);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Log(' mais aucun coffre local - incoherence, effacer le cache');
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
ssNoSnapshot:
|
ssNoSnapshot:
|
||||||
@@ -204,26 +243,19 @@ begin
|
|||||||
LSw.Stop;
|
LSw.Stop;
|
||||||
FETag := LRes.ETag;
|
FETag := LRes.ETag;
|
||||||
if FETag <> '' then Log(' ETag retenu : ' + FETag);
|
if FETag <> '' then Log(' ETag retenu : ' + FETag);
|
||||||
|
|
||||||
|
// Ecrit APRES un dechiffrement reussi seulement, et avec son ETag :
|
||||||
|
// les deux doivent rester coherents.
|
||||||
|
try
|
||||||
|
SaveVault(LPlain, FETag);
|
||||||
|
Log(' coffre enregistre localement (chiffre par le Keystore)');
|
||||||
|
except
|
||||||
|
on E: Exception do
|
||||||
|
Log(' cache non ecrit : ' + E.Message);
|
||||||
|
end;
|
||||||
Log(' ' + LSw.ElapsedMilliseconds.ToString + ' ms pour ' +
|
Log(' ' + LSw.ElapsedMilliseconds.ToString + ' ms pour ' +
|
||||||
Length(LPlain).ToString + ' caracteres');
|
Length(LPlain).ToString + ' caracteres');
|
||||||
|
CountEntries(LPlain);
|
||||||
// Compte vivant / corbeille : c'est ce que le vrai client devra
|
|
||||||
// afficher separement.
|
|
||||||
LJson := TJSONObject.ParseJSONValue(LPlain) as TJSONObject;
|
|
||||||
if LJson <> nil then
|
|
||||||
try
|
|
||||||
if LJson.TryGetValue<TJSONArray>('entries', LArr) then
|
|
||||||
begin
|
|
||||||
LLive := 0; LTrash := 0;
|
|
||||||
for I := 0 to LArr.Count - 1 do
|
|
||||||
if (LArr.Items[I] as TJSONObject).GetValue<Integer>('deleted', 0) = 1
|
|
||||||
then Inc(LTrash) else Inc(LLive);
|
|
||||||
Log(' ' + LLive.ToString + ' entrees, ' +
|
|
||||||
LTrash.ToString + ' en corbeille');
|
|
||||||
end;
|
|
||||||
finally
|
|
||||||
LJson.Free;
|
|
||||||
end;
|
|
||||||
except
|
except
|
||||||
on E: Exception do
|
on E: Exception do
|
||||||
Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
|
Log('ERREUR ' + E.ClassName + ' : ' + E.Message);
|
||||||
@@ -272,7 +304,7 @@ begin
|
|||||||
else
|
else
|
||||||
begin
|
begin
|
||||||
Log(' attendu: ' + TESTVECTOR_KEY_HEX);
|
Log(' attendu: ' + TESTVECTOR_KEY_HEX);
|
||||||
Log(' ==> PBKDF2 FAUX — verifier l''encodage UTF-8 du mot de passe');
|
Log(' ==> PBKDF2 FAUX - verifier l''encodage UTF-8 du mot de passe');
|
||||||
Exit; // inutile de tester AES avec une mauvaise cle
|
Exit; // inutile de tester AES avec une mauvaise cle
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -293,11 +325,11 @@ begin
|
|||||||
LKeyHex := LowerCase(THash.DigestAsString(LKey));
|
LKeyHex := LowerCase(THash.DigestAsString(LKey));
|
||||||
Log(' duree : ' + IntToStr(LSw.ElapsedMilliseconds) + ' ms');
|
Log(' duree : ' + IntToStr(LSw.ElapsedMilliseconds) + ' ms');
|
||||||
if LKeyHex = TESTVECTOR_KEY_HEX then
|
if LKeyHex = TESTVECTOR_KEY_HEX then
|
||||||
Log(' ==> IDENTIQUE — adoptable, encodage UTF-8 confirme')
|
Log(' ==> IDENTIQUE - adoptable, encodage UTF-8 confirme')
|
||||||
else
|
else
|
||||||
begin
|
begin
|
||||||
Log(' obtenu : ' + LKeyHex);
|
Log(' obtenu : ' + LKeyHex);
|
||||||
Log(' ==> DIFFERENT — encodage du char[] incompatible, garder le Pascal');
|
Log(' ==> DIFFERENT - encodage du char[] incompatible, garder le Pascal');
|
||||||
end;
|
end;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -312,9 +344,9 @@ begin
|
|||||||
' octets (attendu ' + IntToStr(TESTVECTOR_PLAIN_LEN) + ')');
|
' octets (attendu ' + IntToStr(TESTVECTOR_PLAIN_LEN) + ')');
|
||||||
Log(' sha256 : ' + LSha);
|
Log(' sha256 : ' + LSha);
|
||||||
if LSha = TESTVECTOR_PLAIN_SHA then
|
if LSha = TESTVECTOR_PLAIN_SHA then
|
||||||
Log(' ==> AES-GCM OK — la chaine complete est bonne')
|
Log(' ==> AES-GCM OK - la chaine complete est bonne')
|
||||||
else
|
else
|
||||||
Log(' ==> SHA DIFFERENT — comparer avec testvector.md');
|
Log(' ==> SHA DIFFERENT - comparer avec testvector.md');
|
||||||
|
|
||||||
Log('');
|
Log('');
|
||||||
Log('Debut du clair : ' + Copy(LPlain, 1, 60));
|
Log('Debut du clair : ' + Copy(LPlain, 1, 60));
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ uses
|
|||||||
mainApk in 'mainApk.pas' {Form8},
|
mainApk in 'mainApk.pas' {Form8},
|
||||||
PMA.Crypto in 'android-client\PMA.Crypto.pas',
|
PMA.Crypto in 'android-client\PMA.Crypto.pas',
|
||||||
PMA.TestVector in 'android-client\PMA.TestVector.pas',
|
PMA.TestVector in 'android-client\PMA.TestVector.pas',
|
||||||
PMA.SyncClient in 'android-client\PMA.SyncClient.pas';
|
PMA.SyncClient in 'android-client\PMA.SyncClient.pas',
|
||||||
|
PMA.Store in 'android-client\PMA.Store.pas';
|
||||||
|
|
||||||
{$R *.res}
|
{$R *.res}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user