diff --git a/android-client/PMA.Crypto.pas b/android-client/PMA.Crypto.pas index 25320a2..a8f0a86 100644 --- a/android-client/PMA.Crypto.pas +++ b/android-client/PMA.Crypto.pas @@ -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 diff --git a/android-client/PMA.Store.pas b/android-client/PMA.Store.pas new file mode 100644 index 0000000..3ac61de --- /dev/null +++ b/android-client/PMA.Store.pas @@ -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//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) 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) 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) end; + + JGCMParameterSpecXClass = interface(JAlgorithmParameterSpecXClass) + ['{82706193-A4B5-C0D1-E2F3-6E7F8091A2B3}'] + function init(tLen: Integer; src: TJavaArray): JGCMParameterSpecX; cdecl; + end; + [JavaSignature('javax/crypto/spec/GCMParameterSpec')] + JGCMParameterSpecX = interface(JAlgorithmParameterSpecX) + ['{938172A4-B5C6-D1E2-F304-7F8091A2B3C4}'] + end; + TJGCMParameterSpecX = class(TJavaGenericImport) 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): JKeyX; cdecl; + function containsAlias(alias: JString): Boolean; cdecl; + end; + TJKeyStoreX = class(TJavaGenericImport) 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): JKeyGenParameterSpec_Builder; cdecl; + function setEncryptionPaddings(paddings: TJavaObjectArray): JKeyGenParameterSpec_Builder; cdecl; + function setKeySize(keySize: Integer): JKeyGenParameterSpec_Builder; cdecl; + function build: JKeyGenParameterSpec; cdecl; + end; + TJKeyGenParameterSpec_Builder = class(TJavaGenericImport) 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) 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) 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): TJavaArray; cdecl; + function getIV: TJavaArray; cdecl; + end; + TJCipherX = class(TJavaGenericImport) end; + +function ToJava(const B: TBytes): TJavaArray; +var I: Integer; +begin + Result := TJavaArray.Create(Length(B)); + for I := 0 to High(B) do Result.Items[I] := B[I]; +end; + +function FromJava(const A: TJavaArray): 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; +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.Create(1); + LModes.Items[0] := StringToJString('GCM'); + LPaddings := TJavaObjectArray.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('etag', ''); + APlainJson := LWrap.GetValue('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. diff --git a/android-client/PMA.SyncClient.pas b/android-client/PMA.SyncClient.pas index 947423c..f9d9c30 100644 --- a/android-client/PMA.SyncClient.pas +++ b/android-client/PMA.SyncClient.pas @@ -3,7 +3,7 @@ unit PMA.SyncClient; 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. - 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. Le serveur repond 404 aussi bien pour un jeton invalide que pour un diff --git a/mainApk.pas b/mainApk.pas index 5967e6d..3e928d7 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -28,7 +28,7 @@ const // // Le port differe selon la plateforme : sur Windows on attaque Apache // 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. {$IF DEFINED(ANDROID)} 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'; {$ENDIF} // 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'; type @@ -53,6 +53,7 @@ type FETag: string; procedure RunSelfTest(Sender: TObject); procedure RunFetch(Sender: TObject); + procedure CountEntries(const APlainJson: string); procedure Log(const AMsg: string); public constructor Create(AOwner: TComponent); override; @@ -64,22 +65,22 @@ var implementation uses - System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient; + System.JSON, PMA.Crypto, PMA.TestVector, PMA.SyncClient, PMA.Store; {$R *.fmx} constructor TForm8.Create(AOwner: TComponent); begin inherited; - Caption := 'PM — crypto self-test'; + Caption := 'PM - crypto self-test'; // 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 // passe, puis les deux boutons. // // 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. FBtn := TButton.Create(Self); FBtn.Parent := Self; @@ -137,6 +138,26 @@ begin 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('entries', LArr) then Exit; + LLive := 0; LTrash := 0; + for I := 0 to LArr.Count - 1 do + if (LArr.Items[I] as TJSONObject).GetValue('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); var LUrl, LTok, LPwd: string; @@ -154,12 +175,20 @@ begin var LClient: TSyncClient; LRes: TSyncResult; - LPlain: string; + LPlain, LCached, LCachedETag: string; LSw: TStopwatch; - LJson: TJSONObject; - LArr: TJSONArray; - LLive, LTrash, I: Integer; 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); // 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 @@ -177,7 +206,17 @@ begin case LRes.Status of ssNotModified: 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; end; ssNoSnapshot: @@ -204,26 +243,19 @@ begin LSw.Stop; FETag := LRes.ETag; 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 ' + Length(LPlain).ToString + ' caracteres'); - - // 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('entries', LArr) then - begin - LLive := 0; LTrash := 0; - for I := 0 to LArr.Count - 1 do - if (LArr.Items[I] as TJSONObject).GetValue('deleted', 0) = 1 - then Inc(LTrash) else Inc(LLive); - Log(' ' + LLive.ToString + ' entrees, ' + - LTrash.ToString + ' en corbeille'); - end; - finally - LJson.Free; - end; + CountEntries(LPlain); except on E: Exception do Log('ERREUR ' + E.ClassName + ' : ' + E.Message); @@ -272,7 +304,7 @@ begin else begin 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 end; @@ -293,11 +325,11 @@ begin LKeyHex := LowerCase(THash.DigestAsString(LKey)); Log(' duree : ' + IntToStr(LSw.ElapsedMilliseconds) + ' ms'); if LKeyHex = TESTVECTOR_KEY_HEX then - Log(' ==> IDENTIQUE — adoptable, encodage UTF-8 confirme') + Log(' ==> IDENTIQUE - adoptable, encodage UTF-8 confirme') else begin Log(' obtenu : ' + LKeyHex); - Log(' ==> DIFFERENT — encodage du char[] incompatible, garder le Pascal'); + Log(' ==> DIFFERENT - encodage du char[] incompatible, garder le Pascal'); end; end; @@ -312,9 +344,9 @@ begin ' octets (attendu ' + IntToStr(TESTVECTOR_PLAIN_LEN) + ')'); Log(' sha256 : ' + LSha); 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 - Log(' ==> SHA DIFFERENT — comparer avec testvector.md'); + Log(' ==> SHA DIFFERENT - comparer avec testvector.md'); Log(''); Log('Debut du clair : ' + Copy(LPlain, 1, 60)); diff --git a/pmserverApk.dpr b/pmserverApk.dpr index 09f544e..e01cd25 100644 --- a/pmserverApk.dpr +++ b/pmserverApk.dpr @@ -6,7 +6,8 @@ uses mainApk in 'mainApk.pas' {Form8}, PMA.Crypto in 'android-client\PMA.Crypto.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}