From 642dfdb077f250ded6a991eb05074257ccbb54c0 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:41:35 +0100 Subject: [PATCH] perf(android): the key is derived once per session, not once per save Adding an entry took five to seven seconds. The log now says where they went: key derived in 4395 ms. Every save re-derived the master password - 600000 iterations - to arrive at exactly the key that had just been used. The remedy was already half written: EncryptContainerWithKey exists for this reason, but only within a single save. The key now lives for the session, and is reused only when the container carries the same salt and the same iteration count - the desktop may have re-encrypted its own with a fresh salt, and a key that does not fit fails on GCM's tag while the message blames the password, which would be right. Nothing new is exposed: the plaintext vault is already in memory, and the cache already holds the password that produces this key. It goes with them, at lock - salt included, or the next open would think it holds a usable key and decrypt against nothing. The envelope is read in one place now rather than copied per entry point: at three copies, a drift between them would surface as a wrong-password error. Measured against the real repository: HTTP 200, decrypted, 8 entries - so the envelope refactor holds. The reuse line itself is not yet measured on the device. --- android-client/PMA.Crypto.pas | 101 +++++++++++++++++++++++++--------- mainApk.pas | 57 +++++++++++++++++-- 2 files changed, 127 insertions(+), 31 deletions(-) diff --git a/android-client/PMA.Crypto.pas b/android-client/PMA.Crypto.pas index 35d842b..33a7df8 100644 --- a/android-client/PMA.Crypto.pas +++ b/android-client/PMA.Crypto.pas @@ -98,6 +98,21 @@ function EncryptContainer(const APlainJson, APassword: string; function DecryptContainerEx(const AContainerJson, APassword: string; out AKey, ASalt: TBytes; out AIterations: Integer): string; +/// Le sel et le nombre d'iterations du conteneur, SANS rien deriver. +/// +/// Sert a repondre a une seule question : la cle qu'on tient deja convient-elle +/// a ce conteneur ? Y repondre coute une lecture de JSON, alors que la deriver +/// pour rien coute quatre a six secondes. +function ContainerKdfParams(const AContainerJson: string; + out ASalt: TBytes; out AIterations: Integer): Boolean; + +/// Dechiffre avec une cle DEJA derivee - le pendant d'EncryptContainerWithKey. +/// L'APPELANT DOIT AVOIR VERIFIE que le sel et les iterations du conteneur sont +/// ceux de cette cle : sans quoi le dechiffrement echoue sur la marque de GCM, +/// et le message parlera d'un mot de passe faux alors qu'il est bon. +function DecryptContainerWithKey(const AContainerJson: string; + const AKey: TBytes): string; + /// Rechiffre avec une cle DEJA derivee. L'IV, lui, est retire a chaque appel : /// reutiliser un IV avec la meme cle casse GCM entierement, alors que /// reutiliser le sel ne fait que redonner la meme cle - ce qui est justement @@ -725,13 +740,15 @@ begin Result := DecryptContainerEx(AContainerJson, APassword, LKey, LSalt, LIter); end; -function DecryptContainerEx(const AContainerJson, APassword: string; - out AKey, ASalt: TBytes; out AIterations: Integer): string; +/// L'enveloppe du conteneur, lue UNE SEULE FOIS pour les trois entrees +/// publiques. Elle a ete recopiee tant qu'il n'y en avait qu'une ; a trois, une +/// divergence entre les copies se paierait par un dechiffrement qui accuse le +/// mot de passe. +procedure ReadContainer(const AContainerJson: string; + out ASalt, AIv, ACt: TBytes; out AIterations: Integer); var LObj: TJSONObject; LFormat, LKdf: string; - LIterations: Integer; - LSalt, LIv, LCt, LKey, LPlain: TBytes; begin LObj := TJSONObject.ParseJSONValue(AContainerJson) as TJSONObject; if LObj = nil then @@ -747,32 +764,66 @@ begin if LKdf <> 'pbkdf2-sha256' then raise EPMACrypto.CreateFmt('Unsupported KDF: "%s"', [LKdf]); - LIterations := LObj.GetValue('kdf_iterations', 600000); - LSalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('kdf_salt', '')); - LIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('iv', '')); - LCt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('ciphertext', '')); + AIterations := LObj.GetValue('kdf_iterations', 600000); + ASalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('kdf_salt', '')); + AIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('iv', '')); + ACt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue('ciphertext', '')); - if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then + if (Length(ASalt) = 0) or (Length(AIv) = 0) or (Length(ACt) = 0) then raise EPMACrypto.Create('Container is missing salt, iv or ciphertext'); - - // 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 - // fallback, and is the only one outside Android. - LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32); - if LKey = nil then - LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32); - LPlain := AesGcmDecrypt(LKey, LIv, LCt); - Result := TEncoding.UTF8.GetString(LPlain); - // Rendus a l'appelant : ils lui evitent une seconde derivation s'il - // rechiffre juste apres. - AKey := LKey; - ASalt := LSalt; - AIterations := LIterations; finally LObj.Free; end; end; +function ContainerKdfParams(const AContainerJson: string; + out ASalt: TBytes; out AIterations: Integer): Boolean; +var + LIv, LCt: TBytes; +begin + Result := True; + try + ReadContainer(AContainerJson, ASalt, LIv, LCt, AIterations); + except + // Une enveloppe illisible n'est pas une erreur ICI : l'appelant va tenter + // le dechiffrement, qui dira ce qui ne va pas avec son propre message. + Result := False; + ASalt := nil; + AIterations := 0; + end; +end; + +function DecryptContainerWithKey(const AContainerJson: string; + const AKey: TBytes): string; +var + LSalt, LIv, LCt: TBytes; + LIterations: Integer; +begin + ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations); + Result := TEncoding.UTF8.GetString(AesGcmDecrypt(AKey, LIv, LCt)); +end; + +function DecryptContainerEx(const AContainerJson, APassword: string; + out AKey, ASalt: TBytes; out AIterations: Integer): string; +var + LIterations: Integer; + LSalt, LIv, LCt, LKey: TBytes; +begin + ReadContainer(AContainerJson, LSalt, LIv, LCt, LIterations); + // 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 + // fallback, and is the only one outside Android. + LKey := DeriveKeyPBKDF2Native(APassword, LSalt, LIterations, 32); + if LKey = nil then + LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32); + Result := TEncoding.UTF8.GetString(AesGcmDecrypt(LKey, LIv, LCt)); + // Rendus a l'appelant : ils lui evitent une seconde derivation s'il + // rechiffre juste apres, ou s'il rouvre le meme conteneur plus tard. + AKey := LKey; + ASalt := LSalt; + AIterations := LIterations; +end; + end. diff --git a/mainApk.pas b/mainApk.pas index bca4b29..5206979 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -116,6 +116,20 @@ type FUrl, FToken, FPwd: string; /// Le compte de cet appareil. Vide tant qu'aucun coffre local n'existe. FUser: string; + /// LA CLE DEJA DERIVEE, et de quoi savoir a quel conteneur elle convient. + /// + /// Chaque enregistrement rederivait le mot de passe : 600 000 iterations, + /// quatre a six secondes, pour retrouver exactement la cle qui venait de + /// servir. C'est ce qui faisait durer un ajout d'entree cinq a sept + /// secondes. Elle ne se reutilise QUE si le conteneur porte le meme sel et + /// le meme nombre d'iterations - le poste peut avoir rechiffre le sien + /// entre-temps, avec un sel neuf, et une cle qui ne convient pas echouerait + /// sur la marque de GCM en accusant le mot de passe, qui serait bon. + /// + /// Rien de nouveau n'est expose : le coffre EN CLAIR est deja en memoire. + /// Elle part au verrouillage, avec lui. + FKey, FKeySalt: TBytes; + FKeyIters: Integer; /// Code d'ouverture du cache local, choisi dans l'application. JAMAIS /// ENREGISTRE : il est vide a chaque lancement et doit etre retape. C'est /// ce qui le distingue du jeton, dont la persistance est un confort sans @@ -204,6 +218,9 @@ type procedure PatchSelfCheck; {$ENDIF} procedure OpenEntry(AVaultIndex: Integer); + /// Ouvre un conteneur en evitant la derivation quand la cle en main + /// convient. Retient au passage celle qui a servi. + function OpenContainer(const AContainer, APwd: string): string; procedure RefuseChange(const AMessage: string); procedure CommitChange(const ALabel, AComment, AReopenUuid: string; const APatch: TFunc); @@ -612,6 +629,26 @@ end; /// desactive son bouton en envoyant, donc un refus qui se contente de parler /// laisse un formulaire mort avec la saisie derriere. La regle s'est deja fait /// oublier trois fois - a l'editeur, puis au formulaire de creation. +function TForm8.OpenContainer(const AContainer, APwd: string): string; +var + LSalt: TBytes; + LIters: Integer; + LSw: TStopwatch; +begin + LSw := TStopwatch.StartNew; + if (FKey <> nil) and ContainerKdfParams(AContainer, LSalt, LIters) + and (LIters = FKeyIters) and (Length(LSalt) = Length(FKeySalt)) + and CompareMem(Pointer(LSalt), Pointer(FKeySalt), Length(LSalt)) then + begin + Result := DecryptContainerWithKey(AContainer, FKey); + Log(Format(' opened with the key already in hand, in %d ms', + [LSw.ElapsedMilliseconds])); + Exit; + end; + Result := DecryptContainerEx(AContainer, APwd, FKey, FKeySalt, FKeyIters); + Log(Format(' key derived in %d ms', [LSw.ElapsedMilliseconds])); +end; + procedure TForm8.RefuseChange(const AMessage: string); begin WebCall('PM.toast(' + JsArg(AMessage) + ')'); @@ -671,8 +708,8 @@ begin // cache ensuite : l'inverse laisserait un cache en avance sur // l'autorite si l'ecriture echouait entre les deux. LSw := TStopwatch.StartNew; - LPlain := DecryptContainerEx(LoadLocalContainer, FSyncPwd, - LKey, LSalt, LIter); + LPlain := OpenContainer(LoadLocalContainer, FSyncPwd); + LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters; LNew := APatch(LPlain); Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); LSw := TStopwatch.StartNew; @@ -707,8 +744,8 @@ begin // reutilise. Deriver deux fois le meme mot de passe coutait une // seconde fois quatre a six secondes, pour une cle equivalente. LSw := TStopwatch.StartNew; - LPlain := DecryptContainerEx(LGet.Container, FSyncPwd, - LKey, LSalt, LIter); + LPlain := OpenContainer(LGet.Container, FSyncPwd); + LKey := FKey; LSalt := FKeySalt; LIter := FKeyIters; LNew := APatch(LPlain); Log(Format('save: decrypt+patch in %d ms', [LSw.ElapsedMilliseconds])); except @@ -1034,6 +1071,14 @@ begin // ecrase ce que l'utilisateur vient de taper - l'auto-verrouillage le faisait // donc sans le moindre geste de sa part, et le champ jeton semblait ne rien // retenir. Verrouiller efface le clair ; la configuration n'a rien a y voir. + // + // LA CLE DERIVEE PART AUSSI. Elle ouvre le conteneur a elle seule, sans le + // mot de passe : la garder apres un verrouillage reviendrait a ne pas + // verrouiller. Le sel la suit, sinon la prochaine ouverture croirait tenir + // une cle valable et dechiffrerait avec du vide. + FKey := nil; + FKeySalt := nil; + FKeyIters := 0; ShowUnlock; end; @@ -1156,7 +1201,7 @@ begin LDoc: TJSONObject; begin try - LPlain := DecryptContainerEx(LoadLocalContainer, APwd, LKey, LSalt, LIters); + LPlain := OpenContainer(LoadLocalContainer, APwd); LOwner := ''; LDoc := TJSONObject.ParseJSONValue(LPlain) as TJSONObject; if LDoc <> nil then @@ -1595,7 +1640,7 @@ begin LSw := TStopwatch.StartNew; // Volontairement AVANT de retenir l'ETag : un echec ici doit laisser // FETag vide pour que le prochain essai retelecharge. - LPlain := DecryptContainer(LRes.Container, APwd); + LPlain := OpenContainer(LRes.Container, APwd); LSw.Stop; FETag := LRes.ETag; Log(' decrypted in ' + LSw.ElapsedMilliseconds.ToString + ' ms');