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