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:
r-zakarya
2026-08-14 18:04:39 +01:00
parent 90ac75525a
commit 02d89c58d2
4 changed files with 419 additions and 0 deletions
+73
View File
@@ -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));
})();