From ae48fadb6b578acf4205aa64782f608b94795c99 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:08:34 +0100 Subject: [PATCH] feat(android): encryption, verified by a third implementation Section 4 step 2. EncryptContainer produces the exact container the desktop writes: PBKDF2-SHA256, AES-256-GCM with the tag appended as WebCrypto does, a 32-byte salt and a 12-byte IV drawn per call from SecureRandom on Android and BCryptGenRandom on Windows - never Random, since a predictable salt or IV ruins the encryption that uses it and nothing in the output would show it. The check matters as much as the code. A round trip with itself only proves internal consistency, and it passed while the container carried two defects. Node - the same primitives as WebCrypto - found both by reading a container the phone had produced: - Delphi's base64 wraps at 76 characters; WebCrypto's never does. Lenient decoders accept it, but depending on their leniency is not a guarantee. - the accented literal in the check came out of the COMPILER already broken: this source has no byte-order mark, so Delphi reads it as ANSI. The defect was in the test rather than the encryption, and only the cross-check could tell those apart. The check runs at launch in DEBUG and leaves selfcheck.json in the private directory so it can be re-read from the PC. Also fixed on the way: TPath resolves to FMX.Objects.TPath - a drawing shape - because FMX.Objects comes later in the uses clause, and the error then talks about an unknown Combine rather than a name collision. Co-Authored-By: Claude Opus 5 --- android-client/PLAN.md | 26 ++++- android-client/PMA.Crypto.pas | 206 +++++++++++++++++++++++++++++++++- mainApk.pas | 62 +++++++++- 3 files changed, 288 insertions(+), 6 deletions(-) diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 35a6d7b..772e09f 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -356,10 +356,28 @@ d'interface d'édition. 1. ~~**`updated_at` dans `TVaultEntry`**~~ — **fait**, et déjà affiché comme *Modifié le* dans le détail. Rien n'était arbitrable sans lui. -2. **AES-GCM en chiffrement.** On n'a que le déchiffrement, des deux côtés - (JNI et CNG). Il faut aussi un sel de 32 octets et un IV de 12 octets tirés - d'un générateur cryptographique — `SecureRandom` côté Android, - `BCryptGenRandom` côté Windows. **Jamais** `Random`. +2. ~~**AES-GCM en chiffrement**~~ — **fait le 2026-08-17**, et vérifié par une + implémentation tierce. `EncryptContainer` produit le conteneur exact du + poste ; sel de 32 octets et IV de 12 tirés de `SecureRandom` (Android) ou + `BCryptGenRandom` (Windows), jamais `Random`. + + **La vérification compte autant que le code.** Un aller-retour avec + soi-même ne prouve que la cohérence interne : il passait alors que le + conteneur portait deux défauts. C'est Node — mêmes primitives que WebCrypto + — qui les a montrés en relisant un conteneur produit par le téléphone : + + - le base64 de Delphi est **coupé à 76 caractères**, celui de WebCrypto ne + l'est jamais. Les décodeurs tolérants l'acceptent, mais dépendre de leur + tolérance n'est pas une garantie ; `B64NoWrap` produit désormais la même + forme. + - le littéral accentué du contrôle sortait **déjà abîmé du compilateur** — + ce fichier n'a pas de marque d'ordre d'octets, et Delphi le lit en ANSI. + Le défaut était dans le test, pas dans le chiffrement, mais seul le + contrôle croisé pouvait le distinguer. + + Le contrôle tourne au lancement en `DEBUG` et dépose `selfcheck.json` dans + le répertoire privé, pour être relu depuis le PC : + `adb shell run-as … cat …/files/selfcheck.json`. 3. **`PUT` dans [sync.php](../server-php/sync.php)**, qui répond aujourd'hui 405. Même authentification, `If-Match` obligatoire, **412** si l'ETag ne correspond plus, écriture atomique (fichier temporaire puis renommage — diff --git a/android-client/PMA.Crypto.pas b/android-client/PMA.Crypto.pas index 7fbbe84..b348375 100644 --- a/android-client/PMA.Crypto.pas +++ b/android-client/PMA.Crypto.pas @@ -35,7 +35,9 @@ unit PMA.Crypto; interface uses - System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash; + System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash, + // System.DateUtils : TTimeZone, pour horodater le conteneur en UTC. + System.DateUtils; type EPMACrypto = class(Exception); @@ -72,6 +74,23 @@ function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes; /// than as "wrong password". function DecryptContainer(const AContainerJson, APassword: string): string; +/// N octets d'un generateur CRYPTOGRAPHIQUE - SecureRandom sur Android, +/// BCryptGenRandom sur Windows. JAMAIS Random : un sel ou un IV previsible +/// ruine le chiffrement qui les emploie, et rien dans le resultat ne le +/// montrerait. Leve plutot que de rendre des octets faibles. +function SecureRandomBytes(ACount: Integer): TBytes; + +/// AES-256-GCM chiffrement. Le tag de 16 octets est colle A LA FIN du +/// resultat, comme le fait WebCrypto - c'est ce que DecryptContainer et le +/// poste Windows attendent tous les deux. +function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes; + +/// Texte JSON -> conteneur complet, dans le format exact que le poste ecrit +/// (`pm-encrypted-export-v1`). Sel et IV tires a chaque appel : deux +/// chiffrements du meme coffre ne se ressemblent pas, et c'est voulu. +function EncryptContainer(const APlainJson, APassword: string; + AIterations: Integer = 600000): string; + implementation {$IF DEFINED(ANDROID)} @@ -119,6 +138,12 @@ function BCryptGenerateSymmetricKey(hAlgorithm: Pointer; var phKey: Pointer; pbKeyObject: PByte; cbKeyObject: Cardinal; pbSecret: PByte; cbSecret, dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL; function BCryptDestroyKey(hKey: Pointer): Integer; stdcall; external BCRYPT_DLL; +function BCryptEncrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal; + pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte; + cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer; + stdcall; external BCRYPT_DLL; +function BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte; + cbBuffer: Cardinal; dwFlags: Cardinal): Integer; stdcall; external BCRYPT_DLL; function BCryptDecrypt(hKey: Pointer; pbInput: PByte; cbInput: Cardinal; pPaddingInfo: Pointer; pbIV: PByte; cbIV: Cardinal; pbOutput: PByte; cbOutput: Cardinal; var pcbResult: Cardinal; dwFlags: Cardinal): Integer; @@ -295,8 +320,10 @@ type JCipherClass = interface(JObjectClass) ['{E081D312-94A5-B6C7-02FD-1E8F90011223}'] function _GetDECRYPT_MODE: Integer; cdecl; + function _GetENCRYPT_MODE: Integer; cdecl; function getInstance(transformation: JString): JCipher; cdecl; property DECRYPT_MODE: Integer read _GetDECRYPT_MODE; + property ENCRYPT_MODE: Integer read _GetENCRYPT_MODE; end; [JavaSignature('javax/crypto/Cipher')] JCipher = interface(JObject) @@ -306,6 +333,21 @@ type end; TJCipher = class(TJavaGenericImport) end; + // Declare ICI plutot qu'en important Androidapi.JNI.Java.Security : cette + // unite redeclarerait JKey et consorts, et Delphi resout par le dernier uses + // - de quoi relier silencieusement le code existant a d'autres interfaces. + JSecureRandom = interface; + JSecureRandomClass = interface(JObjectClass) + ['{9C1D2E3F-4A5B-6C7D-8E9F-0A1B2C3D4E5F}'] + function init: JSecureRandom; cdecl; + end; + [JavaSignature('java/security/SecureRandom')] + JSecureRandom = interface(JObject) + ['{AD2E3F40-5B6C-7D8E-9F0A-1B2C3D4E5F60}'] + procedure nextBytes(bytes: TJavaArray); cdecl; + end; + TJSecureRandom = class(TJavaGenericImport) end; + function ToJavaBytes(const B: TBytes): TJavaArray; var I: Integer; @@ -484,10 +526,172 @@ begin end; {$ENDIF} +// --------------------------------------------------------------------------- +// Alea et chiffrement +// --------------------------------------------------------------------------- + +function SecureRandomBytes(ACount: Integer): TBytes; +{$IF DEFINED(ANDROID)} +var + LRnd: JSecureRandom; + LArr: TJavaArray; +begin + if ACount <= 0 then Exit(nil); + // Le constructeur sans argument s'ensemence tout seul depuis le systeme ; + // fournir une graine soi-meme ne ferait que l'affaiblir. + LRnd := TJSecureRandom.JavaClass.init; + LArr := TJavaArray.Create(ACount); + try + LRnd.nextBytes(LArr); + Result := FromJavaBytes(LArr); + finally + LArr.Free; + end; + if Length(Result) <> ACount then + raise EPMACrypto.Create('SecureRandom returned the wrong length'); +end; +{$ELSEIF DEFINED(MSWINDOWS)} +const + BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002; +begin + if ACount <= 0 then Exit(nil); + SetLength(Result, ACount); + // Handle nul + drapeau systeme : on prend le generateur par defaut de + // Windows sans avoir a ouvrir puis fermer un fournisseur. + if BCryptGenRandom(nil, PByte(Result), ACount, + BCRYPT_USE_SYSTEM_PREFERRED_RNG) <> 0 then + raise EPMACrypto.Create('BCryptGenRandom failed'); +end; +{$ELSE} +begin + raise EPMACrypto.Create('No secure random source on this platform'); +end; +{$ENDIF} + +function AesGcmEncrypt(const AKey, AIv, APlainText: TBytes): TBytes; +{$IF DEFINED(ANDROID)} +var + LCipher: JCipher; + LKeySpec: JSecretKeySpec; + LGcm: JGCMParameterSpec; +begin + LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey), + StringToJString('AES')); + // 128 bits de tag, comme au dechiffrement et comme WebCrypto. + LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv)); + LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipher.JavaClass.ENCRYPT_MODE, LKeySpec, LGcm); + // Java rend deja le tag colle a la fin - meme disposition que WebCrypto. + Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(APlainText))); +end; +{$ELSEIF DEFINED(MSWINDOWS)} +var + LAlg, LKey: Pointer; + LInfo: TBCryptAuthCipherModeInfo; + LTag, LCt: TBytes; + LOutLen: Cardinal; + LMode: string; +begin + LAlg := nil; + LKey := nil; + if BCryptOpenAlgorithmProvider(LAlg, 'AES', nil, 0) <> 0 then + raise EPMACrypto.Create('BCryptOpenAlgorithmProvider(AES) failed'); + try + LMode := 'ChainingModeGCM'; + if BCryptSetProperty(LAlg, 'ChainingMode', PByte(PWideChar(LMode)), + (Length(LMode) + 1) * SizeOf(WideChar), 0) <> 0 then + raise EPMACrypto.Create('BCryptSetProperty(GCM) failed'); + if BCryptGenerateSymmetricKey(LAlg, LKey, nil, 0, + PByte(AKey), Length(AKey), 0) <> 0 then + raise EPMACrypto.Create('BCryptGenerateSymmetricKey failed'); + + // CNG veut le tag DANS la structure, pas dans le flux : on l'y recueille, + // puis on le colle a la fin pour rendre la meme forme que WebCrypto. + SetLength(LTag, 16); + SetLength(LCt, Length(APlainText)); + FillChar(LInfo, SizeOf(LInfo), 0); + LInfo.cbSize := SizeOf(LInfo); + LInfo.dwInfoVersion := AUTH_MODE_INFO_VERSION; + LInfo.pbNonce := PByte(AIv); + LInfo.cbNonce := Length(AIv); + LInfo.pbTag := PByte(LTag); + LInfo.cbTag := Length(LTag); + + LOutLen := 0; + if BCryptEncrypt(LKey, PByte(APlainText), Length(APlainText), @LInfo, + nil, 0, PByte(LCt), Length(LCt), LOutLen, 0) <> 0 then + raise EPMACrypto.Create('BCryptEncrypt failed'); + SetLength(LCt, LOutLen); + Result := Concat(LCt, LTag); + finally + if LKey <> nil then BCryptDestroyKey(LKey); + BCryptCloseAlgorithmProvider(LAlg, 0); + end; +end; +{$ELSE} +begin + raise EPMACrypto.Create('AES-GCM is not wired for this platform'); +end; +{$ENDIF} + // --------------------------------------------------------------------------- // Container // --------------------------------------------------------------------------- +/// Base64 SANS retour a la ligne. TNetEncoding.Base64 coupe a 76 caracteres, +/// comme le courrier electronique ; WebCrypto n'en met aucun. Les decodeurs +/// tolerants ignorent ces retours - `atob` retire les blancs, Node aussi - mais +/// produire exactement la meme forme que l'autre implementation retire une +/// dependance a leur tolerance. +function B64NoWrap(const ABytes: TBytes): string; +var + LEnc: TBase64Encoding; +begin + LEnc := TBase64Encoding.Create(0); // 0 = jamais de coupure + try + Result := LEnc.EncodeBytesToString(ABytes); + finally + LEnc.Free; + end; +end; + +function EncryptContainer(const APlainJson, APassword: string; + AIterations: Integer): string; +var + LObj: TJSONObject; + LSalt, LIv, LKey, LCt: TBytes; +begin + if APassword = '' then + raise EPMACrypto.Create('Refusing to encrypt with an empty password'); + + // TIRES A CHAQUE APPEL, tous les deux. Reutiliser un IV avec la meme cle + // casse GCM entierement - ce n'est pas une precaution de style. + LSalt := SecureRandomBytes(32); + LIv := SecureRandomBytes(12); + + LKey := DeriveKeyPBKDF2Native(APassword, LSalt, AIterations, 32); + if LKey = nil then + LKey := DeriveKeyPBKDF2(APassword, LSalt, AIterations, 32); + LCt := AesGcmEncrypt(LKey, LIv, TEncoding.UTF8.GetBytes(APlainJson)); + + LObj := TJSONObject.Create; + try + // Memes cles, memes valeurs que encryptExportPayload cote poste : c'est ce + // conteneur-la que les deux clients savent relire. + LObj.AddPair('format', 'pm-encrypted-export-v1'); + LObj.AddPair('kdf', 'pbkdf2-sha256'); + LObj.AddPair('kdf_iterations', TJSONNumber.Create(AIterations)); + LObj.AddPair('kdf_salt', B64NoWrap(LSalt)); + LObj.AddPair('iv', B64NoWrap(LIv)); + LObj.AddPair('ciphertext', B64NoWrap(LCt)); + LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss"Z"', + TTimeZone.Local.ToUniversalTime(Now))); + Result := LObj.ToJSON; + finally + LObj.Free; + end; +end; + function DecryptContainer(const AContainerJson, APassword: string): string; var LObj: TJSONObject; diff --git a/mainApk.pas b/mainApk.pas index c1a7b1b..234f243 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -32,7 +32,7 @@ interface uses System.SysUtils, System.Types, System.UITypes, System.Classes, System.Diagnostics, System.Rtti, System.NetEncoding, System.DateUtils, - System.Generics.Collections, + System.Generics.Collections, System.IOUtils, FMX.Types, FMX.Controls, FMX.Forms, FMX.Graphics, FMX.Dialogs, FMX.Platform, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Layouts, FMX.Edit, FMX.ListBox, FMX.Objects, @@ -185,6 +185,9 @@ type procedure WebCall(const AJavaScript: string); procedure PushVault; procedure PushConfig; +{$IFDEF DEBUG} + procedure CryptoSelfCheck; +{$ENDIF} procedure OpenEntry(AVaultIndex: Integer); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -286,6 +289,15 @@ begin PushConfig; ShowUnlock; +{$IFDEF DEBUG} + // VERIFICATION DU CHIFFREMENT AVANT TOUTE ECRITURE REELLE (§4). Elle depose + // aussi un conteneur sur le disque prive, pour qu'il soit relu par l'AUTRE + // implementation : un aller-retour avec soi-meme ne prouve que la coherence + // interne, et c'est la compatibilite avec le poste qui decide si une + // publication depuis le telephone est lisible ou perdue. + TThread.ForceQueue(nil, procedure begin CryptoSelfCheck; end); +{$ENDIF} + // Ouverture automatique du coffre local. Differee apres la construction : // l'invite biometrique s'afficherait sinon avant la premiere peinture de la // fenetre, sur un fond vide. @@ -404,6 +416,54 @@ begin SetLength(FWebPending, 0); end; +/// Chiffre un petit conteneur, le relit, et le laisse sur le disque prive pour +/// qu'une autre implementation le verifie. Iterations volontairement basses : +/// ce qu'on eprouve ici est le CABLAGE - cle, IV, place du tag - pas le cout +/// de la derivation, qui a son propre vecteur de test. +procedure TForm8.CryptoSelfCheck; +const + // Points de code : ce fichier n'a pas de BOM, un accent ecrit tel quel y + // devient deux glyphes et la cle derivee ne serait plus celle qu'on croit. + PWD = 'selfcheck-password-'#$00E9#$00E0#$00FC; + ITERS = 1000; +var + LPlain, LContainer, LBack, LPath: string; + LSw: TStopwatch; +begin + try + // Accents EN POINTS DE CODE, comme le mot de passe : le controle croise a + // montre que ce litteral-ci sortait deja abime du compilateur, et un + // aller-retour avec soi-meme ne pouvait pas le voir. + LPlain := '{"version":1,"entries":[{"uuid":"u1","title":"accent '#$00E9#$00E0'"}]}'; + LSw := TStopwatch.StartNew; + LContainer := EncryptContainer(LPlain, PWD, ITERS); + LBack := DecryptContainer(LContainer, PWD); + LSw.Stop; + if LBack = LPlain then + Log(Format('selfcheck: encrypt/decrypt round-trip OK (%d ms)', + [LSw.ElapsedMilliseconds])) + else + Log('selfcheck: ROUND-TRIP MISMATCH - encryption is not usable'); + + // Depose pour relecture externe. Le mot de passe et les iterations sont + // dans le commentaire de tete du fichier de test cote PC, pas ici. + // GetHomePath, comme PMA.Store : sur Android c'est /data/data// + // files, prive a l'application. Documents n'existe pas ici. + // + // QUALIFIE : FMX.Objects declare aussi un TPath - une forme graphique - et + // il vient apres System.IOUtils dans la clause uses, donc c'est lui que le + // compilateur choisit. Le message parle alors d'un « Combine » inconnu, ce + // qui n'oriente pas vers un conflit de noms. + LPath := System.IOUtils.TPath.Combine( + System.IOUtils.TPath.GetHomePath, 'selfcheck.json'); + TFile.WriteAllText(LPath, LContainer, TEncoding.UTF8); + Log('selfcheck: container written for cross-check'); + except + on E: Exception do + Log('selfcheck FAILED: ' + E.ClassName + ' - ' + E.Message); + end; +end; + /// Descend les coordonnees du serveur dans les champs de la page. procedure TForm8.PushConfig; var