diff --git a/README.md b/README.md index 0f8745e..166388e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Previous versions of the protocol are not currently documented in the spec, and | v0 | use all 128 bits of the counter | | v1 | use only 64 bits of the counter | | v2 (current) | use only 64 bits and also zero out the other half to maximise the space before it wraps | +| org.matrix.msc4016.v3 | streaming file transfers (AES-GCM and 96-bit IV) | ## Encryption @@ -23,6 +24,7 @@ The library will encrypt to the following protocol versions: | Protocol | Browser | Node.js | | --- | --- | --- | | Encrypt | v2 | v2 | +| Encrypt | org.matrix.msc4016.v3 | - | ## Decryption @@ -33,3 +35,5 @@ The library supports decryption of the following protocol versions: | Decrypt v0 | ✅ | ❌ | | Decrypt v1 | ✅ | ❌ | | Decrypt v2 | ✅ | ✅ | +| Decrypt org.matrix.msc4016.v3 | ✅ | ❌ | + diff --git a/src/index.ts b/src/index.ts index 59df89a..044c012 100644 --- a/src/index.ts +++ b/src/index.ts @@ -102,6 +102,9 @@ export async function decryptAttachment(ciphertextBuffer: ArrayBuffer, info: IEn : nodejs.decryptAttachment(Buffer.from(ciphertextBuffer), info); } +export const EncryptTransform = hasWebcrypto ?? webcrypto.EncryptTransform; +export const DecryptTransform = hasWebcrypto ?? webcrypto.DecryptTransform; + /** * Encode a typed array of uint8 as unpadded base64. * @param {Uint8Array} uint8Array The data to encode. diff --git a/src/webcrypto.ts b/src/webcrypto.ts index 8d70093..4bacbf7 100644 --- a/src/webcrypto.ts +++ b/src/webcrypto.ts @@ -84,6 +84,242 @@ export async function decryptAttachment(ciphertextBuffer: ArrayBuffer, info: IEn ); } +/** + * TransformStream for encrypting MSC4016 v3 streaming attachments + * + * The Streams API assumes that you connect "from left to right", and that you take a readable source + * (e.g. fetch body) and pipe it into the writable sink of the next node (i.e. the input of the encrypter), which in + * turn makes the encrypted result available as a readable, which can be piped onwards. In other words, the input + * of the transformer should be a writable, and the output should be a readable. + * + * Readables are sources, Writables are sinks, Transforms turn writables into readables. + * Readables can be piped into writables via readable.pipeTo() + * Readables can be piped through transforms (to be in turn readable) via readable.pipeThrough(). + * + * So, to use this, do something like: + * + * const encryptTransform = new EncryptTransform(); + * const info = await encryptTransform.init(); + * const writable = fs.createWriteStream(); + * const response = await fetch(); + * response.body.pipeThrough(encryptTransform).pipeTo(writable); + * + * N.B. 'extends TransformStream' requires ES6 target due to https://github.com/microsoft/TypeScript/issues/12949 + * or perhaps https://www.npmjs.com/package/@webcomponents/webcomponentsjs#custom-elements-es5-adapterjs. + * Alternatively this could be a function which returns a TransformStream rather than extending one, but mandating ES6 + * seems reasonable these days. + */ +export class EncryptTransform extends TransformStream { + info?: IEncryptedFile; + started = false; + blockId = 0; + baseIv: Uint8Array = new Uint8Array(12); + cryptoKey?: CryptoKey; + + constructor() { + super({ + start: (controller: TransformStreamDefaultController) => {}, + transform: async (buffer: Uint8Array, controller: TransformStreamDefaultController) => { + await this.handle(buffer, controller); + }, + flush: (controller: TransformStreamDefaultController) => {}, + }); + } + + async init(): Promise { + // generate a full 12-bytes of IV, as it shouldn't matter if AES-GCM overflows + // and more entropy is better. + window.crypto.getRandomValues(this.baseIv.subarray(0, 12)); + + // Load the encryption key. + this.cryptoKey = await window.crypto.subtle.generateKey( + { 'name': 'AES-GCM', 'length': 256 }, true, ['encrypt', 'decrypt'], + ); + // Export the Key as JWK. + const exportedKey = await window.crypto.subtle.exportKey('jwk', this.cryptoKey); + + this.info = { + v: 'org.matrix.msc4016.v3', + key: exportedKey as IEncryptedFileJWK, + iv: encodeBase64(this.baseIv), + hashes: { + // no hashes need for AES-GCM + }, + }; + + return this.info; + } + + async handle(value: Uint8Array, controller: TransformStreamDefaultController) { + const blockIdArray = new Uint32Array([this.blockId]); + + const iv = new Uint8Array(16); + iv.set(this.baseIv, 4); + + // concatenate the IV with the block sequence number so it gets hashed down to a 96-bit value within GCM + // to mitigate IV reuse + iv.set(new Uint8Array(blockIdArray.buffer), 0); + + let ciphertextBuffer; + try { + ciphertextBuffer = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv, length: 128, additionalData: blockIdArray.buffer }, this.cryptoKey, value, + ); + } catch (e) { + console.error('failed to encrypt', e); + throw (e); + } + + if (!this.started) { + controller.enqueue(new Uint8Array([77, 88, 67, 0x03])); // magic number + this.started = true; + } + + // merge writes so we write one block in one go + const outBuffer = new Uint8Array(16 + ciphertextBuffer.byteLength); + // We write our custom headers to make the GCM block seekable, and to let partially decrypted content + // be visible to the recipient while benefiting from the GCM authentication tags. + outBuffer.set([0xFF, 0xFF, 0xFF, 0xFF], 0); // registration marker + outBuffer.set(new Uint8Array(blockIdArray.buffer), 4); + outBuffer.set(new Uint8Array(new Uint32Array([ciphertextBuffer.byteLength]).buffer), 8); + // TODO: calculate a CRC + outBuffer.set([0x00, 0x00, 0x00, 0x00], 12); + outBuffer.set(new Uint8Array(ciphertextBuffer), 16); + controller.enqueue(outBuffer); + + this.blockId++; + } +} + +/** + * TransformStream for decrypting MSC4016 v3 streaming attachments + * + * Use this with something like: + * + * const decryptTransform = new DecryptTransform(info); + * await decryptTransform.init(); + * const writable = fs.createWriteStream(); + * const response = await fetch(); + * response.body.pipeThrough(decryptTransform).pipeTo(writable); + * + * N.B. 'extends TransformStream' requires ES6 target due to https://github.com/microsoft/TypeScript/issues/12949 + * or perhaps https://www.npmjs.com/package/@webcomponents/webcomponentsjs#custom-elements-es5-adapterjs. + * Alternatively this could be a function which returns a TransformStream rather than extending one, but mandating ES6 + * seems reasonable these days. + */ +export class DecryptTransform extends TransformStream { + info: IEncryptedFile; + cryptoKey?: CryptoKey; + + started = false; + buffer: Uint8Array = new Uint8Array(65536); + bufferOffset = 0; + + constructor(info: IEncryptedFile) { + super({ + start: (controller: TransformStreamDefaultController) => {}, + transform: async (buffer: Uint8Array, controller: TransformStreamDefaultController) => { + await this.handle(buffer, controller); + }, + flush: (controller: TransformStreamDefaultController) => {}, + }); + this.info = info; + if (info === undefined || info.key === undefined || info.iv === undefined) { + throw new Error('Invalid info. Missing info.key or info.iv'); + } + if (info.v && info.v != 'org.matrix.msc4016.v3') { + throw new Error(`Unsupported protocol version: ${info.v}`); + } + } + + async init() { + this.cryptoKey = await window.crypto.subtle.importKey( + 'jwk', this.info.key, { 'name': 'AES-GCM' }, false, ['encrypt', 'decrypt'], + ); + } + + async handle(value: Uint8Array, controller: TransformStreamDefaultController) { + // increase the buffer size if needed + if (this.bufferOffset + value.length > this.buffer.length) { + const newBuffer = new Uint8Array(this.buffer.length + value.length); + newBuffer.set(this.buffer); + this.buffer = newBuffer; + } + + this.buffer.set(value, this.bufferOffset); + this.bufferOffset += value.length; + + // handle magic number. TODO: handle random access. + if (!this.started) { + const magicLen = 4; + if (this.bufferOffset > magicLen) { + if (this.buffer[0] != 77 || + this.buffer[1] != 88 || + this.buffer[2] != 67 || + this.buffer[3] != 0x03) { + throw new Error('Can\'t decrypt stream: invalid magic number'); + } else { + this.started = true; + // rewind away the magic number + const newBuffer = new Uint8Array(this.buffer.length); + newBuffer.set(this.buffer.slice(magicLen)); + this.buffer = newBuffer; + this.bufferOffset -= magicLen; + } + } + } + + const iv = new Uint8Array(16); + iv.set(decodeBase64(this.info.iv), 4); + + // handle blocks + const headerLen = 16; + while (this.bufferOffset > headerLen) { + const header = new Uint32Array(this.buffer.buffer, 0, 12); + if (header[0] != 0xFFFFFFFF) { + // TODO: handle random access and hunt for the registration code if it's not at the beginning + console.log('Chunk doesn\'t begin with a registration code', header, header[0]); + throw new Error('Chunk doesn\'t begin with a registration code'); + } + const blockId = header[1]; + const blockLength = header[2]; + // const crc = header[3]; + if (this.bufferOffset >= headerLen + blockLength) { + // we can decrypt! + // TODO: check the CRC + + // TODO: terminate stream if blockId wraps all the way around (to prevent IV reuse) + const blockIdArray = new Uint32Array([blockId]); + + // concatenate the IV with the block sequence number so it gets hashed down to a 96-bit value within GCM + // to mitigate IV reuse + iv.set(new Uint8Array(blockIdArray.buffer), 0); + + let plaintextBuffer; + try { + plaintextBuffer = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv, length: 128, additionalData: blockIdArray.buffer }, + this.cryptoKey, this.buffer.slice(headerLen, headerLen + blockLength), + ); + } catch (e) { + console.error('failed to decrypt (probably invalid IV or corrupt stream)', e); + throw (e); + } + + controller.enqueue(plaintextBuffer); + + // wind back the buffer, if any + const newBuffer = new Uint8Array(this.buffer.length); + newBuffer.set(this.buffer.slice(headerLen + blockLength)); + this.buffer = newBuffer; + this.bufferOffset -= (headerLen + blockLength); + } else { + break; + } + } + } +} + export function encodeBase64(uint8Array: Uint8Array): string { // Misinterpt the Uint8Array as Latin-1. // window.btoa expects a unicode string with codepoints in the range 0-255. @@ -114,6 +350,8 @@ export function decodeBase64(base64: string): Uint8Array { export default { encryptAttachment, decryptAttachment, + EncryptTransform, + DecryptTransform, encodeBase64, decodeBase64, }; diff --git a/tsconfig.json b/tsconfig.json index e6178e9..4120655 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es6", "esModuleInterop": true, "module": "commonjs", "moduleResolution": "node",