02d89c58d2
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.
74 lines
2.6 KiB
JavaScript
74 lines
2.6 KiB
JavaScript
// 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));
|
|
})();
|