pithos/test/web-crypt.test.js
Local Dev 2eedcb8ff6 Pithos browser app, phase 1: encryption and S3 signing on Web Crypto
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.
2026-10-05 02:14:43 +02:00

108 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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