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.
This commit is contained in:
@@ -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<JKeyClass, JKey>) 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<JAlgorithmParameterSpecClass, JAlgorithmParameterSpec>) end;
|
||||||
|
|
||||||
|
JSecretKeySpecClass = interface(JKeyClass)
|
||||||
|
['{AC4D9FDE-5061-7283-CEBF-DA4B5C6D7E8F}']
|
||||||
|
function init(key: TJavaArray<Byte>; algorithm: JString): JSecretKeySpec; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/spec/SecretKeySpec')]
|
||||||
|
JSecretKeySpec = interface(JKey)
|
||||||
|
['{BD5EA0EF-6172-8394-DFCA-EB5C6D7E8F90}']
|
||||||
|
end;
|
||||||
|
TJSecretKeySpec = class(TJavaGenericImport<JSecretKeySpecClass, JSecretKeySpec>) end;
|
||||||
|
|
||||||
|
JGCMParameterSpecClass = interface(JAlgorithmParameterSpecClass)
|
||||||
|
['{CE6FB1F0-7283-94A5-E0DB-FC6D7E8F9001}']
|
||||||
|
function init(tLen: Integer; src: TJavaArray<Byte>): JGCMParameterSpec; cdecl;
|
||||||
|
end;
|
||||||
|
[JavaSignature('javax/crypto/spec/GCMParameterSpec')]
|
||||||
|
JGCMParameterSpec = interface(JAlgorithmParameterSpec)
|
||||||
|
['{DF70C201-8394-A5B6-F1EC-0D7E8F900112}']
|
||||||
|
end;
|
||||||
|
TJGCMParameterSpec = class(TJavaGenericImport<JGCMParameterSpecClass, JGCMParameterSpec>) 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<Byte>): TJavaArray<Byte>; cdecl;
|
||||||
|
end;
|
||||||
|
TJCipher = class(TJavaGenericImport<JCipherClass, JCipher>) end;
|
||||||
|
|
||||||
|
function ToJavaBytes(const B: TBytes): TJavaArray<Byte>;
|
||||||
|
var
|
||||||
|
I: Integer;
|
||||||
|
begin
|
||||||
|
Result := TJavaArray<Byte>.Create(Length(B));
|
||||||
|
for I := 0 to High(B) do
|
||||||
|
Result.Items[I] := B[I];
|
||||||
|
end;
|
||||||
|
|
||||||
|
function FromJavaBytes(const A: TJavaArray<Byte>): 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<string>('format', '');
|
||||||
|
if LFormat <> 'pm-encrypted-export-v1' then
|
||||||
|
raise EPMACrypto.CreateFmt('Unknown container format: "%s"', [LFormat]);
|
||||||
|
|
||||||
|
LKdf := LObj.GetValue<string>('kdf', '');
|
||||||
|
if LKdf <> 'pbkdf2-sha256' then
|
||||||
|
raise EPMACrypto.CreateFmt('Unsupported KDF: "%s"', [LKdf]);
|
||||||
|
|
||||||
|
LIterations := LObj.GetValue<Integer>('kdf_iterations', 600000);
|
||||||
|
LSalt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('kdf_salt', ''));
|
||||||
|
LIv := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('iv', ''));
|
||||||
|
LCt := TNetEncoding.Base64.DecodeStringToBytes(LObj.GetValue<string>('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.
|
||||||
@@ -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));
|
||||||
|
})();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user