Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental MSC4016 implementation #26

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -33,3 +35,5 @@ The library supports decryption of the following protocol versions:
| Decrypt v0 | ✅ | ❌ |
| Decrypt v1 | ✅ | ❌ |
| Decrypt v2 | ✅ | ✅ |
| Decrypt org.matrix.msc4016.v3 | ✅ | ❌ |

3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
238 changes: 238 additions & 0 deletions src/webcrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array, Uint8Array> {
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<IEncryptedFile> {
// 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<Uint8Array, Uint8Array> {
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.
Expand Down Expand Up @@ -114,6 +350,8 @@ export function decodeBase64(base64: string): Uint8Array {
export default {
encryptAttachment,
decryptAttachment,
EncryptTransform,
DecryptTransform,
encodeBase64,
decodeBase64,
};
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
Expand Down
Loading