pithos/test/crypt.test.js
Local Dev 14f5ad6d74 Pithos: client-side encryption for hosted drives
Hosted drives sit on a server that holds the Sia connection key, so private
files must be unreadable there. Names are encrypted per path segment with
AES-SIV (deterministic, so folders and prefix listing still work on the
server) and bodies with chunked AES-256-GCM bound to drive and path, with
sizes computable both ways so uploads stream and range reads stay cheap.
2026-10-04 01:20:05 +02:00

120 lines
6.1 KiB
JavaScript

// 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/);
});