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
+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;