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 - 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
+341
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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}