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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-08-17 17:08:34 +01:00
parent c73ac66313
commit ae48fadb6b
3 changed files with 288 additions and 6 deletions
+22 -4
View File
@@ -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 —
+205 -1
View File
@@ -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<JCipherClass, JCipher>) 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<Byte>); cdecl;
end;
TJSecureRandom = class(TJavaGenericImport<JSecureRandomClass, JSecureRandom>) end;
function ToJavaBytes(const B: TBytes): TJavaArray<Byte>;
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<Byte>;
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<Byte>.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;
+61 -1
View File
@@ -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/<paquet>/
// 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