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:
+22
-4
@@ -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 —
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user