// Client-side encryption: published AES-CMAC / AES-SIV vectors, then the // name and body formats round-tripped, tampered with and range-read. import test from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { Readable } from 'node:stream'; import { cmac, sivEncrypt, sivDecrypt, driveKeys, encryptPath, decryptPath, encryptBody, decryptBody, cipherRange, cipherSize, plainSize, parseRange, HEADER_LEN, chunkSize, } from '../core/crypt.js'; const hex = (s) => Buffer.from(s.replace(/\s+/g, ''), 'hex'); test('AES-CMAC matches RFC 4493', () => { const k = hex('2b7e1516 28aed2a6 abf71588 09cf4f3c'); assert.equal(cmac(k, Buffer.alloc(0)).toString('hex'), 'bb1d6929e95937287fa37d129b756746'); assert.equal(cmac(k, hex('6bc1bee2 2e409f96 e93d7e11 7393172a')).toString('hex'), '070a16b46b4d4144f79bdd9dd04a287c'); const m40 = hex('6bc1bee2 2e409f96 e93d7e11 7393172a ae2d8a57 1e03ac9c 9eb76fac 45af8e51 30c81c46 a35ce411'); assert.equal(cmac(k, m40).toString('hex'), 'dfa66747de9ae63030ca32611497c827'); }); test('AES-SIV matches RFC 5297 A.1', () => { const key = hex('fffefdfc fbfaf9f8 f7f6f5f4 f3f2f1f0 f0f1f2f3 f4f5f6f7 f8f9fafb fcfdfeff'); const ad = hex('10111213 14151617 18191a1b 1c1d1e1f 20212223 24252627'); const pt = hex('11223344 55667788 99aabbcc ddee'); const out = sivEncrypt(key, [ad], pt); assert.equal(out.toString('hex'), '85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c'); assert.deepEqual(sivDecrypt(key, [ad], out), pt); const bad = Buffer.from(out); bad[20] ^= 1; assert.equal(sivDecrypt(key, [ad], bad), null); }); const master = crypto.randomBytes(32); const dk = driveKeys(master, 'photos'); test('paths: deterministic per folder, reversible, folders kept', () => { const a = encryptPath(dk, 'trips/2026/beach.jpg'); assert.equal(a, encryptPath(dk, 'trips/2026/beach.jpg')); assert.match(a, /^~[A-Za-z0-9_-]+\/~[A-Za-z0-9_-]+\/~[A-Za-z0-9_-]+$/); assert.ok(!a.includes('beach') && !a.includes('trips')); assert.deepEqual(decryptPath(dk, a), { path: 'trips/2026/beach.jpg', encrypted: true }); // A folder prefix lists its own children. const folder = encryptPath(dk, 'trips/2026/'); assert.ok(a.startsWith(folder)); assert.deepEqual(decryptPath(dk, folder), { path: 'trips/2026/', encrypted: true }); // Same name, different folder: different ciphertext. assert.notEqual(encryptPath(dk, 'x/a.txt').split('/')[1], encryptPath(dk, 'y/a.txt').split('/')[1]); // Unicode survives. assert.equal(decryptPath(dk, encryptPath(dk, 'Μουσική/ποίημα ✓.txt')).path, 'Μουσική/ποίημα ✓.txt'); }); test('paths: plain names, other drives and other keys stay plain', () => { assert.deepEqual(decryptPath(dk, 'public/index.html'), { path: 'public/index.html', encrypted: false }); const enc = encryptPath(dk, 'secret.txt'); assert.equal(decryptPath(driveKeys(master, 'other'), enc).encrypted, false); assert.equal(decryptPath(driveKeys(crypto.randomBytes(32), 'photos'), enc).encrypted, false); assert.throws(() => encryptPath(dk, 'a//b'), /empty folder/); }); async function collect(stream) { const parts = []; for await (const p of stream) parts.push(p); return Buffer.concat(parts); } // Feeds the ciphertext in odd-sized pieces, like a network would. function jagged(buf) { const parts = []; for (let i = 0; i < buf.length;) { const n = 1 + Math.floor(Math.random() * 70000); parts.push(buf.subarray(i, i + n)); i += n; } return Readable.from(parts); } async function roundTrip(size, range) { const plain = crypto.randomBytes(size); const { stream, size: csize } = encryptBody(dk, 'a/b.bin', jagged(plain), size); const ct = await collect(stream); assert.equal(ct.length, csize); assert.equal(plainSize(csize), size); const r = cipherRange(ct.length, range); const out = await collect(decryptBody(dk, 'a/b.bin', ct.subarray(0, HEADER_LEN), jagged(ct.subarray(r.from, r.to + 1)), r)); const want = range ? plain.subarray(range.start, Math.min(range.end, size - 1) + 1) : plain; assert.ok(out.equals(want), `size ${size} range ${JSON.stringify(range)}`); return ct; } test('bodies round-trip at chunk boundaries', async () => { const cs = chunkSize(); for (const n of [0, 1, 15, cs - 1, cs, cs + 1, 3 * cs + 77]) await roundTrip(n); }); test('bodies: range reads fetch only the chunks they need', async () => { const cs = chunkSize(); await roundTrip(3 * cs + 500, { start: 0, end: 99 }); await roundTrip(3 * cs + 500, { start: cs - 10, end: cs + 10 }); await roundTrip(3 * cs + 500, { start: 3 * cs + 400, end: 9e15 }); const r = cipherRange(cipherSize(3 * cs + 500), { start: 2 * cs + 1, end: 2 * cs + 2 }); assert.equal(r.to - r.from + 1, cs + 16, 'one chunk'); assert.deepEqual(parseRange('bytes=-10', 100), { start: 90, end: 99 }); assert.deepEqual(parseRange('bytes=5-', 100), { start: 5, end: 99 }); assert.equal(cipherRange(cipherSize(10), { start: 50, end: 60 }).unsatisfiable, true); }); test('bodies: tampering, truncation, swapping and length lies are caught', async () => { const cs = chunkSize(); const size = 2 * cs + 10; const plain = crypto.randomBytes(size); const ct = await collect(encryptBody(dk, 'f.bin', plain, size).stream); const full = cipherRange(ct.length); const read = (buf, path = 'f.bin', key = dk) => collect(decryptBody(key, path, buf.subarray(0, HEADER_LEN), Readable.from([buf.subarray(HEADER_LEN)]), full)); const flipped = Buffer.from(ct); flipped[HEADER_LEN + cs + 40] ^= 1; await assert.rejects(read(flipped), /changed on the server/); await assert.rejects(read(ct, 'g.bin'), /changed on the server/, 'moved to another name'); await assert.rejects(read(ct, 'f.bin', driveKeys(master, 'other')), /changed on the server/); // Dropping the last chunk: the previous one is not flagged last. const cut = ct.subarray(0, HEADER_LEN + 2 * (cs + 16)); await assert.rejects(collect(decryptBody(dk, 'f.bin', cut.subarray(0, HEADER_LEN), Readable.from([cut.subarray(HEADER_LEN)]), full)), /ended early/); await assert.rejects(collect(encryptBody(dk, 'x', Buffer.alloc(10), 9).stream), /longer than/); await assert.rejects(collect(encryptBody(dk, 'x', Buffer.alloc(8), 9).stream), /ended before/); });