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.
120 lines
6.1 KiB
JavaScript
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/);
|
|
});
|