diff --git a/app/blobslicer.js b/app/blobslicer.js new file mode 100644 index 00000000..41caaea3 --- /dev/null +++ b/app/blobslicer.js @@ -0,0 +1,41 @@ +const streams = require('web-streams-polyfill'); + +class BlobSlicer { + constructor(blob, size, decrypt) { + this.blob = blob; + this.size = size; + this.index = 0; + this.decrypt = decrypt; + } + + pull(controller) { + return new Promise((resolve, reject) => { + const bytesLeft = this.blob.size - this.index; + if (bytesLeft <= 0) { + controller.close(); + return resolve(); + } + let size = 0; + if (this.decrypt && this.index === 0) { + size = Math.min(21, bytesLeft); + } else { + size = Math.min(this.size, bytesLeft); + } + const blob = this.blob.slice(this.index, this.index + size); + const reader = new FileReader(); + reader.onload = function() { + controller.enqueue(new Uint8Array(this.result)); + resolve(); + }; + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + this.index += size; + }); + } +} + +export default class BlobSliceStream extends streams.ReadableStream { + constructor(blob, size, decrypt) { + super(new BlobSlicer(blob, size, decrypt)); + } +} diff --git a/app/ece.js b/app/ece.js new file mode 100644 index 00000000..c894e6b3 --- /dev/null +++ b/app/ece.js @@ -0,0 +1,237 @@ +require('buffer'); + +const NONCE_LENGTH = 12; +const TAG_LENGTH = 16; +const KEY_LENGTH = 16; +const MODE_ENCRYPT = 'encrypt'; +const MODE_DECRYPT = 'decrypt'; + +const encoder = new TextEncoder(); + +function generateSalt(len) { + const randSalt = new Uint8Array(len); + window.crypto.getRandomValues(randSalt); + return randSalt.buffer; +} + +/* +mode: string, either 'encrypt' or 'decrypt' +ikm: Uint8Array containing key of KEY_LENGTH length +rs: int containing record size, optional +salt: ArrayBuffer containing salt of KEY_LENGTH length, optional +The transform stream takes data as UInt8Arrays on the writable side, and outputs +UInt8Arrays on the readable side. +*/ +export default class ECETransformer { + constructor(mode, ikm, rs, salt) { + this.mode = mode; + this.prevChunk; + this.params = {}; + this.seq = 0; + this.firstchunk = true; + this.rs = rs || 1024; + this.ikm = ikm.buffer; + this.params.salt = salt; + if (!salt) { + this.params.salt = generateSalt(KEY_LENGTH); + } + } + + async generateKey() { + const inputKey = await window.crypto.subtle.importKey( + 'raw', + this.ikm, + 'HKDF', + false, + ['deriveKey'] + ); + + return window.crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: this.params.salt, + info: encoder.encode('Content-Encoding: aes128gcm\0'), + hash: 'SHA-256' + }, + inputKey, + { + name: 'AES-GCM', + length: 128 + }, + false, + ['encrypt', 'decrypt'] + ); + } + + async generateNonceBase() { + const inputKey = await window.crypto.subtle.importKey( + 'raw', + this.ikm, + 'HKDF', + false, + ['deriveKey'] + ); + + const base = await window.crypto.subtle.exportKey( + 'raw', + await window.crypto.subtle.deriveKey( + { + name: 'HKDF', + salt: this.params.salt, + info: encoder.encode('Content-Encoding: nonce\0'), + hash: 'SHA-256' + }, + inputKey, + { + name: 'AES-GCM', + length: 128 + }, + true, + ['encrypt', 'decrypt'] + ) + ); + + return Buffer.from(base.slice(0, NONCE_LENGTH)); + } + + generateNonce(seq) { + const nonce = Buffer.from(this.params.nonceBase); + const m = nonce.readUIntBE(nonce.length - 4, 4); + const xor = (m ^ seq) >>> 0; //forces unsigned int xor + nonce.writeUIntBE(xor, nonce.length - 4, 4); + + const m2 = nonce.readUIntBE(nonce.length - 8, 4); + const xor2 = (m2 ^ (seq >>> 4)) >>> 0; + nonce.writeUIntBE(xor2, nonce.length - 8, 4); + + return nonce; + } + + pad(data, isLast) { + const len = data.length; + if (len + TAG_LENGTH >= this.rs) { + throw new Error('data too large for record size'); + } + + if (isLast) { + const padding = Buffer.alloc(1); + padding.writeUInt8(2, 0); + return Buffer.concat([data, padding]); + } else { + const padding = Buffer.alloc(this.rs - len - TAG_LENGTH); + padding.fill(0); + padding.writeUInt8(1, 0); + return Buffer.concat([data, padding]); + } + } + + unpad(data, isLast) { + for (let i = data.length - 1; i >= 0; i--) { + if (data[i]) { + if (isLast) { + if (data[i] !== 2) { + throw new Error('delimiter of final record is not 2'); + } + } else { + if (data[i] !== 1) { + throw new Error('delimiter of not final record is not 1'); + } + } + return data.slice(0, i); + } + } + throw new Error('no delimiter found'); + } + + createHeader() { + const nums = Buffer.alloc(5); + nums.writeUIntBE(this.rs, 0, 4); + nums.writeUIntBE(0, 4, 1); + return Buffer.concat([Buffer.from(this.params.salt), nums]); + } + + //salt is arraybuffer, rs is int, length is int + readHeader(buffer) { + if (buffer.length < 21) { + throw new Error('chunk too small for reading header'); + } + const header = {}; + header.salt = buffer.buffer.slice(0, KEY_LENGTH); + header.rs = buffer.readUIntBE(KEY_LENGTH, 4); + const idlen = buffer.readUInt8(KEY_LENGTH + 4); + header.length = idlen + KEY_LENGTH + 5; + return header; + } + + async encryptRecord(buffer, seq, isLast) { + const nonce = this.generateNonce(seq); + const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce }, + this.params.key, + this.pad(buffer, isLast) + ); + return Buffer.from(encrypted); + } + + async decryptRecord(buffer, seq, isLast) { + const nonce = this.generateNonce(seq); + const data = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: nonce, + tagLength: 128 + }, + this.params.key, + buffer + ); + + return this.unpad(Buffer.from(data), isLast); + } + + async start(controller) { + if (this.mode === MODE_ENCRYPT) { + this.params.key = await this.generateKey(); + this.params.nonceBase = await this.generateNonceBase(); + controller.enqueue(this.createHeader()); + } else if (this.mode !== MODE_DECRYPT) { + throw new Error('mode must be either encrypt or decrypt'); + } + } + + async transformPrevChunk(isLast, controller) { + if (this.mode === MODE_ENCRYPT) { + controller.enqueue( + await this.encryptRecord(this.prevChunk, this.seq, isLast) + ); + this.seq++; + } else { + if (this.seq === 0) { + //the first chunk during decryption contains only the header + const header = this.readHeader(this.prevChunk); + this.params.salt = header.salt; + this.rs = header.rs; + this.params.key = await this.generateKey(); + this.params.nonceBase = await this.generateNonceBase(); + } else { + controller.enqueue( + await this.decryptRecord(this.prevChunk, this.seq - 1, isLast) + ); + } + this.seq++; + } + } + + async transform(chunk, controller) { + if (!this.firstchunk) { + await this.transformPrevChunk(false, controller); + } + this.firstchunk = false; + this.prevChunk = Buffer.from(chunk.buffer); + } + + async flush(controller) { + if (this.prevChunk) { + await this.transformPrevChunk(true, controller); + } + } +} diff --git a/test/frontend/tests/streaming-tests.js b/test/frontend/tests/streaming-tests.js new file mode 100644 index 00000000..cd492610 --- /dev/null +++ b/test/frontend/tests/streaming-tests.js @@ -0,0 +1,88 @@ +const streams = require('web-streams-polyfill'); +const ece = require('http_ece'); +require('buffer'); + +import assert from 'assert'; +import { b64ToArray } from '../../../app/utils'; +import ECETransformer from '../../../app/ece.js'; +import BlobSliceStream from '../../../app/blobslicer.js'; + +const decoder = new TextDecoder('utf-8'); +const rs = 36; + +const str = 'You are the dancing queen, young and sweet, only seventeen.'; +const testSalt = 'I1BsxtFttlv3u_Oo94xnmw'; +const keystr = 'yqdlZ-tYemfogSmv7Ws5PQ'; + +const buffer = Buffer.from(str); +const params = { + version: 'aes128gcm', + rs: rs, + salt: testSalt, + keyid: '', + key: keystr +}; + +const encrypted = ece.encrypt(buffer, params); +const decrypted = ece.decrypt(encrypted, params); + +describe('Streaming', function() { + //testing against http_ece's implementation + describe('ECE', function() { + const key = b64ToArray(keystr); + const salt = b64ToArray(testSalt).buffer; + const blob = new Blob([str], { type: 'text/plain' }); + + it('blob slice stream works', async function() { + const rs = await new BlobSliceStream(blob, 100); + const reader = rs.getReader(); + + let result = ''; + let state = await reader.read(); + while (!state.done) { + result = decoder.decode(state.value); + state = await reader.read(); + } + + assert.equal(result, str); + }); + + it('can encrypt', async function() { + const enc = new streams.TransformStream( + new ECETransformer('encrypt', key, rs, salt) + ); + + const rstream = await new BlobSliceStream(blob, rs - 17); + + const reader = rstream.pipeThrough(enc).getReader(); + let result = Buffer.from([]); + + let state = await reader.read(); + while (!state.done) { + result = Buffer.concat([result, state.value]); + state = await reader.read(); + } + + assert.deepEqual(result, encrypted); + }); + + it('can decrypt', async function() { + const encBlob = new Blob([encrypted]); + const dec = new streams.TransformStream( + new ECETransformer('decrypt', key, rs) + ); + + const rstream = await new BlobSliceStream(encBlob, rs, true); + const reader = rstream.pipeThrough(dec).getReader(); + let result = Buffer.from([]); + + let state = await reader.read(); + while (!state.done) { + result = Buffer.concat([result, state.value]); + state = await reader.read(); + } + + assert.deepEqual(result, decrypted); + }); + }); +});