From 02d89c58d2097b3cfef0c63739973610de4295fe Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:04:39 +0100 Subject: [PATCH] feat(android): container crypto unit plus a known-answer test vector PBKDF2 is implemented in Pascal rather than through javax.crypto SecretKeyFactory: PBEKeySpec takes a char[] and the provider decides how those chars become bytes, so a non-ASCII sync password would derive a different key than the browser and fail as 'wrong password'. AES-GCM does go through JNI - one call, hardware-backed, and WebCrypto's tag-appended layout is exactly what Cipher expects. The vector is generated with WebCrypto itself and uses a non-ASCII password on purpose; an ASCII-only vector would have hidden that bug. It records the derived key separately from the plaintext so a failure points at one stage instead of the whole chain. --- android-client/PMA.Crypto.pas | 271 ++++++++++++++++++++++++++++++ android-client/make-testvector.js | 73 ++++++++ android-client/testvector.json | 9 + android-client/testvector.md | 66 ++++++++ 4 files changed, 419 insertions(+) create mode 100644 android-client/PMA.Crypto.pas create mode 100644 android-client/make-testvector.js create mode 100644 android-client/testvector.json create mode 100644 android-client/testvector.md diff --git a/android-client/PMA.Crypto.pas b/android-client/PMA.Crypto.pas new file mode 100644 index 0000000..96c381a --- /dev/null +++ b/android-client/PMA.Crypto.pas @@ -0,0 +1,271 @@ +unit PMA.Crypto; +{ + Opens a pm-encrypted-export-v1 container (the vault snapshot) on Android. + + Format, produced by js/app.import.js encryptExportPayload(): + kdf 'pbkdf2-sha256' + kdf_iterations 600000 + kdf_salt base64, 32 bytes + iv base64, 12 bytes + ciphertext base64, AES-GCM output = ciphertext || 16-byte tag + (payload) UTF-8 JSON + + TWO DIFFERENT ROUTES, on purpose: + + - AES-GCM goes through JNI to javax.crypto. One call, no loop, and the + platform implementation is hardware-accelerated. Java's Cipher expects the + tag appended to the ciphertext, which is exactly what WebCrypto produces — + the buffers are byte-identical, nothing to reshape. + + - PBKDF2 is implemented HERE rather than via SecretKeyFactory, because + javax.crypto.spec.PBEKeySpec takes a char[] and the provider decides how + those chars become bytes: UTF-8 on some, low-byte on others. For a + non-ASCII sync password the derived key would differ from the browser's, + and the failure is silent — "wrong password" on a correct password. Doing + it here fixes the encoding at UTF-8 and makes the match exact by + construction. The cost is 600k HMAC-SHA256 rounds in Pascal; measure it on + a real device (see DeriveKeyPBKDF2's note) before considering the JNI + route again. +} + +interface + +uses + System.SysUtils, System.Classes, System.JSON, System.NetEncoding, System.Hash; + +type + EPMACrypto = class(Exception); + +/// Derive a key the same way WebCrypto's deriveExportKey does: PBKDF2-SHA256 +/// over the UTF-8 bytes of the password. AKeyLenBytes = 32 for AES-256. +function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes; + AIterations, AKeyLenBytes: Integer): TBytes; + +/// AES-256-GCM decrypt. ACipherText carries the 16-byte tag at the end, as +/// produced by WebCrypto. Raises EPMACrypto when the tag does not verify — +/// which is what a wrong password looks like. +function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes; + +/// Full container → plaintext JSON payload. Validates the format marker so a +/// truncated download or an HTML error page fails with a clear message rather +/// than as "wrong password". +function DecryptContainer(const AContainerJson, APassword: string): string; + +implementation + +{$IF DEFINED(ANDROID)} +uses + Androidapi.JNIBridge, Androidapi.JNI.JavaTypes, Androidapi.Helpers; +{$ENDIF} + +// --------------------------------------------------------------------------- +// PBKDF2-SHA256 +// --------------------------------------------------------------------------- + +function XorBytes(const A, B: TBytes): TBytes; +var + I: Integer; +begin + SetLength(Result, Length(A)); + for I := 0 to High(A) do + Result[I] := A[I] xor B[I]; +end; + +function DeriveKeyPBKDF2(const APassword: string; const ASalt: TBytes; + AIterations, AKeyLenBytes: Integer): TBytes; +// Straight RFC 2898. One block is enough for a 32-byte key with SHA-256 +// (hLen = 32), so there is no block loop to get wrong. +// +// PERFORMANCE: 600k HMAC rounds. Budget a few seconds on a mid-range phone and +// run it OFF the UI thread. If it proves unacceptable, the answer is to cache +// the derived key in the Android Keystore behind biometrics — NOT to lower the +// iteration count, which is shared with the desktop and defines the format. +var + LPwd, LBlock, LU, LT: TBytes; + I: Integer; +begin + if AKeyLenBytes > 32 then + raise EPMACrypto.Create('KeyLen > one SHA-256 block is not implemented'); + + LPwd := TEncoding.UTF8.GetBytes(APassword); + + // U1 = HMAC(pwd, salt || INT32BE(1)) + SetLength(LBlock, Length(ASalt) + 4); + if Length(ASalt) > 0 then + Move(ASalt[0], LBlock[0], Length(ASalt)); + LBlock[Length(ASalt)] := 0; + LBlock[Length(ASalt) + 1] := 0; + LBlock[Length(ASalt) + 2] := 0; + LBlock[Length(ASalt) + 3] := 1; + + LU := THashSHA2.GetHMACAsBytes(LBlock, LPwd, THashSHA2.TSHA2Version.SHA256); + LT := Copy(LU, 0, Length(LU)); + for I := 2 to AIterations do + begin + LU := THashSHA2.GetHMACAsBytes(LU, LPwd, THashSHA2.TSHA2Version.SHA256); + LT := XorBytes(LT, LU); + end; + + Result := Copy(LT, 0, AKeyLenBytes); +end; + +// --------------------------------------------------------------------------- +// AES-GCM through javax.crypto +// --------------------------------------------------------------------------- + +{$IF DEFINED(ANDROID)} +type + // Minimal JNI imports. Delphi ships no headers for javax.crypto, and the + // bridge builds its method signatures from these declarations — hence the + // real Java types (Key, AlgorithmParameterSpec) rather than JObject, which + // would generate a signature no method matches. + JKey = interface; + JAlgorithmParameterSpec = interface; + JSecretKeySpec = interface; + JGCMParameterSpec = interface; + JCipher = interface; + + JKeyClass = interface(IJavaClass) + ['{6E0F5B9A-1C2D-4E3F-8A7B-9C0D1E2F3A4B}'] + end; + [JavaSignature('java/security/Key')] + JKey = interface(IJavaInstance) + ['{7F1A6CAB-2D3E-4F50-9B8C-AD1E2F3A4B5C}'] + end; + TJKey = class(TJavaGenericImport) end; + + JAlgorithmParameterSpecClass = interface(IJavaClass) + ['{8A2B7DBC-3E4F-5061-AC9D-BE2F3A4B5C6D}'] + end; + [JavaSignature('java/security/spec/AlgorithmParameterSpec')] + JAlgorithmParameterSpec = interface(IJavaInstance) + ['{9B3C8ECD-4F50-6172-BDAE-CF3A4B5C6D7E}'] + end; + TJAlgorithmParameterSpec = class(TJavaGenericImport) end; + + JSecretKeySpecClass = interface(JKeyClass) + ['{AC4D9FDE-5061-7283-CEBF-DA4B5C6D7E8F}'] + function init(key: TJavaArray; algorithm: JString): JSecretKeySpec; cdecl; + end; + [JavaSignature('javax/crypto/spec/SecretKeySpec')] + JSecretKeySpec = interface(JKey) + ['{BD5EA0EF-6172-8394-DFCA-EB5C6D7E8F90}'] + end; + TJSecretKeySpec = class(TJavaGenericImport) end; + + JGCMParameterSpecClass = interface(JAlgorithmParameterSpecClass) + ['{CE6FB1F0-7283-94A5-E0DB-FC6D7E8F9001}'] + function init(tLen: Integer; src: TJavaArray): JGCMParameterSpec; cdecl; + end; + [JavaSignature('javax/crypto/spec/GCMParameterSpec')] + JGCMParameterSpec = interface(JAlgorithmParameterSpec) + ['{DF70C201-8394-A5B6-F1EC-0D7E8F900112}'] + end; + TJGCMParameterSpec = class(TJavaGenericImport) end; + + JCipherClass = interface(JObjectClass) + ['{E081D312-94A5-B6C7-02FD-1E8F90011223}'] + function _GetDECRYPT_MODE: Integer; cdecl; + function getInstance(transformation: JString): JCipher; cdecl; + property DECRYPT_MODE: Integer read _GetDECRYPT_MODE; + end; + [JavaSignature('javax/crypto/Cipher')] + JCipher = interface(JObject) + ['{F192E423-A5B6-C7D8-130E-2F9001122334}'] + procedure init(opmode: Integer; key: JKey; params: JAlgorithmParameterSpec); cdecl; + function doFinal(input: TJavaArray): TJavaArray; cdecl; + end; + TJCipher = class(TJavaGenericImport) end; + +function ToJavaBytes(const B: TBytes): TJavaArray; +var + I: Integer; +begin + Result := TJavaArray.Create(Length(B)); + for I := 0 to High(B) do + Result.Items[I] := B[I]; +end; + +function FromJavaBytes(const A: TJavaArray): TBytes; +var + I: Integer; +begin + SetLength(Result, A.Length); + for I := 0 to A.Length - 1 do + Result[I] := A.Items[I]; +end; +{$ENDIF} + +function AesGcmDecrypt(const AKey, AIv, ACipherText: TBytes): TBytes; +{$IF DEFINED(ANDROID)} +var + LCipher: JCipher; + LKeySpec: JSecretKeySpec; + LGcm: JGCMParameterSpec; +begin + try + LKeySpec := TJSecretKeySpec.JavaClass.init(ToJavaBytes(AKey), + StringToJString('AES')); + // 128 = tag length in BITS. WebCrypto's default is the same 16 bytes; a + // mismatch here fails authentication on otherwise valid data. + LGcm := TJGCMParameterSpec.JavaClass.init(128, ToJavaBytes(AIv)); + LCipher := TJCipher.JavaClass.getInstance(StringToJString('AES/GCM/NoPadding')); + LCipher.init(TJCipher.JavaClass.DECRYPT_MODE, LKeySpec, LGcm); + Result := FromJavaBytes(LCipher.doFinal(ToJavaBytes(ACipherText))); + except + // AEADBadTagException surfaces here: wrong password, or altered bytes. + // Both are indistinguishable by design — that is what authentication means. + on E: Exception do + raise EPMACrypto.Create('Decryption failed — wrong password or corrupt file'); + end; +end; +{$ELSE} +begin + // Windows/macOS build of the same units: no platform AES-GCM available here. + // The desktop app already has its own crypto; this unit targets Android. + raise EPMACrypto.Create('AES-GCM is only wired for Android in this unit'); +end; +{$ENDIF} + +// --------------------------------------------------------------------------- +// Container +// --------------------------------------------------------------------------- + +function DecryptContainer(const AContainerJson, APassword: string): string; +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 + raise EPMACrypto.Create('Not a JSON container (truncated download?)'); + try + // Checked before anything else: an HTML error page or a half-written file + // would otherwise reach the cipher and be reported as a wrong password. + LFormat := LObj.GetValue('format', ''); + if LFormat <> 'pm-encrypted-export-v1' then + raise EPMACrypto.CreateFmt('Unknown container format: "%s"', [LFormat]); + + LKdf := LObj.GetValue('kdf', ''); + 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', '')); + + if (Length(LSalt) = 0) or (Length(LIv) = 0) or (Length(LCt) = 0) then + raise EPMACrypto.Create('Container is missing salt, iv or ciphertext'); + + LKey := DeriveKeyPBKDF2(APassword, LSalt, LIterations, 32); + LPlain := AesGcmDecrypt(LKey, LIv, LCt); + Result := TEncoding.UTF8.GetString(LPlain); + finally + LObj.Free; + end; +end; + +end. diff --git a/android-client/make-testvector.js b/android-client/make-testvector.js new file mode 100644 index 0000000..f247a4f --- /dev/null +++ b/android-client/make-testvector.js @@ -0,0 +1,73 @@ +// Produces a pm-encrypted-export-v1 container with WebCrypto — the exact +// primitive the browser uses — so the Delphi/Android implementation can be +// checked against a known answer instead of "it seems to work on my file". +// +// node make-testvector.js > testvector.json +// +// The password and the payload are fixed below; keep them in sync with +// testvector.md. Salt and IV are fixed too, so the output is reproducible and +// a diff means a real behavioural change. + +const { webcrypto } = require('node:crypto'); +const { subtle } = webcrypto; + +const PASSWORD = 'sync-test-password-éàü'; // deliberately NON-ASCII: that is + // where char[] vs UTF-8 diverges +const PAYLOAD = { + version: 1, + exported_at: '2026-08-14T12:00:00.000Z', + username: 'testvector', + folders: [{ name: 'Work', color: '#3b82f6', icon: '' }], + entries: [ + { + uuid: '11111111-2222-3333-4444-555555555555', + site: 'example.com', + title: 'Example', + username: 'alice', + password: 'correct horse battery staple', + folder: 'Work', + tags: ['demo'], + kind: 'login', + deleted: 0, + }, + { + uuid: '66666666-7777-8888-9999-000000000000', + site: 'trashed.example', + title: 'Trashed', + username: 'bob', + password: 'in the bin', + folder: 'All', + tags: [], + kind: 'login', + deleted: 1, + deleted_at: '2026-08-01 09:30:00', + }, + ], +}; + +// Fixed, so the file is byte-stable across runs. NEVER do this in the app: +// a reused salt/IV pair with the same key destroys GCM's security. +const salt = Buffer.alloc(32); +for (let i = 0; i < 32; i++) salt[i] = i; +const iv = Buffer.alloc(12); +for (let i = 0; i < 12; i++) iv[i] = 200 + i; + +(async () => { + const base = await subtle.importKey('raw', new TextEncoder().encode(PASSWORD), + 'PBKDF2', false, ['deriveKey']); + const key = await subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 600000, hash: 'SHA-256' }, + base, { name: 'AES-GCM', length: 256 }, false, ['encrypt']); + const ct = await subtle.encrypt({ name: 'AES-GCM', iv }, key, + new TextEncoder().encode(JSON.stringify(PAYLOAD))); + + console.log(JSON.stringify({ + format: 'pm-encrypted-export-v1', + kdf: 'pbkdf2-sha256', + kdf_iterations: 600000, + kdf_salt: salt.toString('base64'), + iv: iv.toString('base64'), + ciphertext: Buffer.from(ct).toString('base64'), + created_at: '2026-08-14T12:00:00.000Z', + }, null, 2)); +})(); diff --git a/android-client/testvector.json b/android-client/testvector.json new file mode 100644 index 0000000..7408c2a --- /dev/null +++ b/android-client/testvector.json @@ -0,0 +1,9 @@ +{ + "format": "pm-encrypted-export-v1", + "kdf": "pbkdf2-sha256", + "kdf_iterations": 600000, + "kdf_salt": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "iv": "yMnKy8zNzs/Q0dLT", + "ciphertext": "31AqOJ83L2TEFOcYaKEdpqKWGY42I4NUq+jdO7pA3j8+v023ECxjSaEnB0NvbufW6dMCgkK9oFwRvoQlLVSvK6bNBwBinJ4ltlbwKqATR2HGHh5eqiqGZayNNdRIFNJZw/jw9lYy28R6uK421uaMRx+mUBs4LGX9U+F2ayFfVF/VlsaHb5I3YMhLCN2zyMxs3j7Y1s69N09Uonm0LGKQ+SiteuXTHWJzAvHFIduLlxPJhCfEKUap4PolQ5PMohJeJYt+sutg7UY6i0QclxpmF2MY2qpbh3gA2mmSntoI2LN8Q41NMqyVn6JY6kXXpEfD1aNVw6dDCqAw2FQJ9HxE+/GBirt009rdS/l59KEnIYRvgBFnQqRTJZNNncHXXh3TsnQ3opKEyYe5qCxnX6E7wGzRawD/sQn2NNWjR8wwQQ68Cs74/RR8stAeuD/VJhJnkwxXPh8eBXHWFjLgFZuan+Ja2FwkjMhkHvIqbfJpbNwnu1mJ8ABtbGqmEHYSGUvLovIwCoQWjBqewlCFvRg0l4h63mUxuLaXx0SrIA3xhS7HqnYVTBp5/EVAu715jKmeDHviiZw6iws5ujd/u0IDUtJY1uANaF1xTBwcfkCnuonBlI8eMbIlcndrLSUN2slm6KxeUjZ3RSpiE9Izh5ix4kann8lygCAZVGkjswB83LmB1dFNH3oqG3+4qUgv7qR0TRZUSMoopCaSxhsVRd6/uBWu+82Nkbij1jMIzaB1+vTxBEHi5DGBNDfV/0evTgoIkC9wJtlEbz/OVCwC", + "created_at": "2026-08-14T12:00:00.000Z" +} \ No newline at end of file diff --git a/android-client/testvector.md b/android-client/testvector.md new file mode 100644 index 0000000..8d5a9a6 --- /dev/null +++ b/android-client/testvector.md @@ -0,0 +1,66 @@ +# Vecteur de test — conteneur `pm-encrypted-export-v1` + +`testvector.json` est produit par `make-testvector.js` avec **WebCrypto**, la +primitive exacte du navigateur. Sel et IV sont figés, donc le fichier est +reproductible : une différence signale un vrai changement de comportement, pas +du bruit. + +> Le fichier de test réutilise un couple sel/IV fixe. C'est acceptable pour un +> vecteur, **jamais** dans l'application : réutiliser un IV avec la même clé +> détruit la sécurité de GCM. + +## Paramètres + +| | | +|---|---| +| Mot de passe | `sync-test-password-éàü` | +| KDF | PBKDF2-SHA256, 600 000 itérations, sel de 32 octets | +| Chiffrement | AES-256-GCM, IV de 12 octets, tag de 16 octets **en fin de `ciphertext`** | + +Le mot de passe est **volontairement non-ASCII**. C'est le seul cas qui +distingue une implémentation correcte d'une implémentation approximative : Java +`PBEKeySpec` prend un `char[]` et laisse le fournisseur choisir l'encodage, ce +qui donne une clé différente pour `é`, `à`, `ü`. Un vecteur en pur ASCII aurait +laissé passer le bug. + +## Valeurs attendues + +Vérifier **dans cet ordre** — chaque étage isole une cause : + +**1. Clé dérivée** (`DeriveKeyPBKDF2`, avant tout déchiffrement) + +``` +6bd7683ea78e163062f02681b03f2906fcf5a3a25025005af4e71982e6e2b286 +``` + +Si elle diffère, le problème est dans PBKDF2 ou dans l'encodage du mot de passe +— surtout pas dans AES. Contrôler d'abord que le mot de passe est bien encodé +en UTF-8 (`sync-test-password-éàü` fait **25 octets**, pas 22 caractères). + +**2. Texte clair** (`AesGcmDecrypt` puis `DecryptContainer`) + +``` +longueur UTF-8 : 572 octets +SHA-256 : ddaee44f8ac8374f1ece6d9ee2abd104d59cd283556f1c4c07afa95ae1dbd2a7 +début : {"version":1,"exported_at":"2026-08-14T12:00:00.000Z","username":"test +``` + +Si la clé est bonne et que le déchiffrement échoue, chercher du côté de la +longueur du tag (128 **bits**) ou d'un tag qui aurait été séparé du corps — +WebCrypto le concatène, `javax.crypto` l'attend concaténé, il n'y a rien à +découper. + +## Contenu + +2 entrées, dont **une en corbeille** (`deleted: 1` avec son `deleted_at`), et +un dossier avec sa couleur. De quoi vérifier que le client mobile affiche la +corbeille séparément au lieu de la mélanger à la liste. + +## Régénérer + +```bash +node make-testvector.js > testvector.json +``` + +En PowerShell, la redirection ajoute un **BOM** qui rend le JSON illisible. +Utiliser `cmd`, ou écrire le fichier sans BOM.