The browser app must read and write the same encrypted files as the desktop Pithos. web/lib/crypt.js reimplements core/crypt.js on Web Crypto (AES-SIV and CMAC built from AES-CBC with a zero IV and AES-CTR, since Web Crypto has neither), and web/lib/sigv4.js the SigV4 signing. Cross-tests: the RFC 5297 vector, identical drive keys and names, bodies encrypted on one side and opened on the other (empty, chunk-edge and multi-chunk files, ranges), tamper and wrong-path rejection, and equal SigV4 headers and presigned URLs.
108 lines
6.3 KiB
JavaScript
108 lines
6.3 KiB
JavaScript
// The browser app's crypto (web/lib) must match core/ byte for byte: files
|
||
// written by the desktop Pithos open in the browser and the other way round.
|
||
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import crypto from 'node:crypto';
|
||
import { Readable } from 'node:stream';
|
||
import * as N from '../core/crypt.js';
|
||
import { S3Client } from '../core/s3.js';
|
||
import * as W from '../web/lib/crypt.js';
|
||
import * as WS from '../web/lib/sigv4.js';
|
||
|
||
const H = (s) => Buffer.from(s.replace(/\s+/g, ''), 'hex');
|
||
const collect = async (it) => { const parts = []; for await (const p of it) parts.push(Buffer.from(p)); return Buffer.concat(parts); };
|
||
|
||
test('AES-SIV matches RFC 5297 A.1 and Node', async () => {
|
||
const key = H('fffefdfc fbfaf9f8 f7f6f5f4 f3f2f1f0 f0f1f2f3 f4f5f6f7 f8f9fafb fcfdfeff');
|
||
const ad = H('10111213 14151617 18191a1b 1c1d1e1f 20212223 24252627');
|
||
const pt = H('11223344 55667788 99aabbcc ddee');
|
||
const want = '85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c';
|
||
assert.equal(Buffer.from(await W.sivEncrypt(new Uint8Array(key), [new Uint8Array(ad)], new Uint8Array(pt))).toString('hex'), want);
|
||
assert.equal(N.sivEncrypt(key, [ad], pt).toString('hex'), want);
|
||
const bad = Uint8Array.from(H(want)); bad[20] ^= 1;
|
||
assert.equal(await W.sivDecrypt(new Uint8Array(key), [new Uint8Array(ad)], bad), null);
|
||
for (const len of [0, 1, 15, 16, 17, 31, 32, 33, 100]) {
|
||
const m = crypto.randomBytes(len);
|
||
assert.equal(Buffer.from(await W.cmac(new Uint8Array(key.subarray(0, 32)), new Uint8Array(m))).toString('hex'), N.cmac(key.subarray(0, 32), m).toString('hex'), `cmac ${len}`);
|
||
}
|
||
});
|
||
|
||
test('drive keys and encrypted names are identical, and decrypt across', async () => {
|
||
const master = crypto.randomBytes(32);
|
||
const n = N.driveKeys(master, 'music-house');
|
||
const w = await W.driveKeys(new Uint8Array(master), 'music-house');
|
||
assert.equal(Buffer.from(w.name).toString('hex'), n.name.toString('hex'));
|
||
assert.equal(Buffer.from(w.body).toString('hex'), n.body.toString('hex'));
|
||
for (const p of ['a.mp3', 'photos/2026/Ünïcødé – long name with spaces.jpg', 'folder/', 'x/y/z/']) {
|
||
const nk = N.encryptPath(n, p);
|
||
assert.equal(await W.encryptPath(w, p), nk, p);
|
||
assert.deepEqual(await W.decryptPath(w, nk), { path: p, encrypted: true });
|
||
assert.deepEqual(N.decryptPath(n, await W.encryptPath(w, p)), { path: p, encrypted: true });
|
||
}
|
||
// plain names stay plain, mixed paths say so
|
||
assert.deepEqual(await W.decryptPath(w, 'public/readme.txt'), { path: 'public/readme.txt', encrypted: false });
|
||
assert.deepEqual(await W.decryptPath(w, '~notours'), { path: '~notours', encrypted: false });
|
||
// another drive's names do not open
|
||
const other = await W.driveKeys(new Uint8Array(master), 'other');
|
||
assert.equal((await W.decryptPath(other, N.encryptPath(n, 'a.mp3'))).encrypted, false);
|
||
});
|
||
|
||
test('file bodies decrypt across, whole and by range', async () => {
|
||
const master = crypto.randomBytes(32);
|
||
const n = N.driveKeys(master, 'docs');
|
||
const w = await W.driveKeys(new Uint8Array(master), 'docs');
|
||
const MiB = 1 << 20;
|
||
for (const size of [0, 1, 1000, MiB, MiB + 5, Math.floor(2.5 * MiB)]) {
|
||
const plain = crypto.randomBytes(size);
|
||
const path = `dir/f-${size}.bin`;
|
||
|
||
// Node encrypts, the browser decrypts (whole file, then a range across a chunk edge)
|
||
const { stream, size: csize } = N.encryptBody(n, path, Readable.from([plain]), size);
|
||
const cipher = await collect(stream);
|
||
assert.equal(cipher.length, csize);
|
||
assert.equal(W.cipherSize(size), csize);
|
||
assert.equal(W.plainSize(csize), size);
|
||
const header = new Uint8Array(cipher.subarray(0, W.HEADER_LEN));
|
||
const ctx = await W.bodyContext(w, path, header);
|
||
const read = (r) => (i) => new Uint8Array(cipher.subarray(W.HEADER_LEN + i * (ctx.cs + W.TAG_LEN), Math.min(cipher.length, W.HEADER_LEN + (i + 1) * (ctx.cs + W.TAG_LEN))));
|
||
const whole = W.cipherRange(cipher.length);
|
||
assert.ok((await collect(W.decryptChunks(ctx, whole, read(whole)))).equals(plain), `whole ${size}`);
|
||
if (size > MiB) {
|
||
const r = W.cipherRange(cipher.length, { start: MiB - 3, end: MiB + 3 });
|
||
assert.ok((await collect(W.decryptChunks(ctx, r, read(r)))).equals(plain.subarray(MiB - 3, MiB + 4)), `range ${size}`);
|
||
assert.deepEqual(r, N.cipherRange(cipher.length, { start: MiB - 3, end: MiB + 3 }));
|
||
}
|
||
|
||
// the browser encrypts, Node decrypts
|
||
const wc = await collect(W.encryptBlob(w, path, new Blob([plain])));
|
||
assert.equal(wc.length, csize);
|
||
const back = await collect(N.decryptBody(n, path, wc.subarray(0, N.HEADER_LEN), Readable.from([wc.subarray(N.HEADER_LEN)]), N.cipherRange(wc.length)));
|
||
assert.ok(back.equals(plain), `browser→node ${size}`);
|
||
}
|
||
});
|
||
|
||
test('a body moved to another path or changed does not open', async () => {
|
||
const master = crypto.randomBytes(32);
|
||
const w = await W.driveKeys(new Uint8Array(master), 'docs');
|
||
const c = await collect(W.encryptBlob(w, 'a.txt', new Blob(['hello'])));
|
||
const header = new Uint8Array(c.subarray(0, W.HEADER_LEN));
|
||
const sealed = new Uint8Array(c.subarray(W.HEADER_LEN));
|
||
await assert.rejects(W.openChunk(await W.bodyContext(w, 'b.txt', header), 0, true, sealed));
|
||
const flipped = Uint8Array.from(sealed); flipped[0] ^= 1;
|
||
await assert.rejects(W.openChunk(await W.bodyContext(w, 'a.txt', header), 0, true, flipped));
|
||
assert.equal(Buffer.from(await W.openChunk(await W.bodyContext(w, 'a.txt', header), 0, true, sealed)).toString(), 'hello');
|
||
});
|
||
|
||
test('SigV4 headers and presigned URLs match core/s3.js', async () => {
|
||
const creds = { accessKeyId: 'AKIDEXAMPLE1234', secretKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' };
|
||
const node = new S3Client({ endpoint: 'https://pithos.silentmode.st', ...creds });
|
||
const now = '20261005T120000Z';
|
||
const query = { 'list-type': '2', prefix: '~abc/', delimiter: '/', 'max-keys': '1000' };
|
||
const n = node.sign('GET', '/my-drive', query, { range: 'bytes=0-99' }, { now });
|
||
const w = await WS.sign({ host: 'pithos.silentmode.st', ...creds }, 'GET', '/my-drive', query, { range: 'bytes=0-99' }, { now });
|
||
assert.deepEqual(w, n);
|
||
assert.equal(WS.objectPath('b', 'a dir/ä+b.txt'), '/b/a%20dir/%C3%A4%2Bb.txt');
|
||
assert.equal(
|
||
await WS.presign({ origin: 'https://pithos.silentmode.st', host: 'pithos.silentmode.st', ...creds }, 'b', 'x/y.mp3', 600, now),
|
||
node.presign('b', 'x/y.mp3', 600, undefined, now));
|
||
});
|