From 2377b2958d297f553ae062aed50dc1c5d4a08b5f Mon Sep 17 00:00:00 2001 From: larabr <7375870+larabr@users.noreply.github.com> Date: Tue, 30 May 2023 22:19:00 +0200 Subject: [PATCH] Use WebCrypto for streamed CFB encryption; for CFB/GCM/EAX, fallback to asmcrypto only if key size is not supported CFB decryption is too slow using WebCrypto (CBC mode), since every block needs to be decrypted separately --- src/crypto/cmac.js | 25 ++++++-- src/crypto/mode/cfb.js | 141 ++++++++++++++++++++++++++++++++++------- src/crypto/mode/eax.js | 28 +++++--- src/crypto/mode/gcm.js | 45 ++++++++----- 4 files changed, 182 insertions(+), 57 deletions(-) diff --git a/src/crypto/cmac.js b/src/crypto/cmac.js index 8c20afc7..19d996a8 100644 --- a/src/crypto/cmac.js +++ b/src/crypto/cmac.js @@ -72,13 +72,6 @@ export default async function CMAC(key) { } async function CBC(key) { - if (util.getWebCrypto() && key.length !== 24) { // WebCrypto (no 192 bit support) see: https://www.chromium.org/blink/webcrypto#TOC-AES-support - key = await webCrypto.importKey('raw', key, { name: 'AES-CBC', length: key.length * 8 }, false, ['encrypt']); - return async function(pt) { - const ct = await webCrypto.encrypt({ name: 'AES-CBC', iv: zeroBlock, length: blockLength * 8 }, key, pt); - return new Uint8Array(ct).subarray(0, ct.byteLength - blockLength); - }; - } if (util.getNodeCrypto()) { // Node crypto library return async function(pt) { const en = new nodeCrypto.createCipheriv('aes-' + (key.length * 8) + '-cbc', key, zeroBlock); @@ -86,6 +79,24 @@ async function CBC(key) { return new Uint8Array(ct); }; } + + if (util.getWebCrypto()) { + try { + key = await webCrypto.importKey('raw', key, { name: 'AES-CBC', length: key.length * 8 }, false, ['encrypt']); + return async function(pt) { + const ct = await webCrypto.encrypt({ name: 'AES-CBC', iv: zeroBlock, length: blockLength * 8 }, key, pt); + return new Uint8Array(ct).subarray(0, ct.byteLength - blockLength); + }; + } catch (err) { + // no 192 bit support in Chromium, which throws `OperationError`, see: https://www.chromium.org/blink/webcrypto#TOC-AES-support + if (err.name !== 'NotSupportedError' && + !(key.length === 24 && err.name === 'OperationError')) { + throw err; + } + util.printDebugError('Browser did not support operation: ' + err.message); + } + } + // asm.js fallback return async function(pt) { return AES_CBC.encrypt(pt, key, false, zeroBlock); diff --git a/src/crypto/mode/cfb.js b/src/crypto/mode/cfb.js index bca03563..6fce90c1 100644 --- a/src/crypto/mode/cfb.js +++ b/src/crypto/mode/cfb.js @@ -96,7 +96,7 @@ export async function encrypt(algo, key, plaintext, iv, config) { */ export async function decrypt(algo, key, ciphertext, iv) { const algoName = enums.read(enums.symmetric, algo); - if (util.getNodeCrypto() && nodeAlgos[algoName]) { // Node crypto library. + if (nodeCrypto && nodeAlgos[algoName]) { // Node crypto library. return nodeDecrypt(algo, key, ciphertext, iv); } if (util.isAES(algo)) { @@ -129,18 +129,122 @@ export async function decrypt(algo, key, ciphertext, iv) { return stream.transform(ciphertext, process, process); } -function aesEncrypt(algo, key, pt, iv, config) { - if ( - util.getWebCrypto() && - key.length !== 24 && // Chrome doesn't support 192 bit keys, see https://www.chromium.org/blink/webcrypto#TOC-AES-support - !util.isStream(pt) && - pt.length >= 3000 * config.minBytesForWebCrypto // Default to a 3MB minimum. Chrome is pretty slow for small messages, see: https://bugs.chromium.org/p/chromium/issues/detail?id=701188#c2 - ) { // Web Crypto - return webEncrypt(algo, key, pt, iv); +class WebCryptoEncryptor { + constructor(algo, key, iv) { + const { blockSize } = getCipher(algo); + this.key = key; + this.prevBlock = iv; + this.nextBlock = new Uint8Array(blockSize); + this.i = 0; // pointer inside next block + this.blockSize = blockSize; + this.zeroBlock = new Uint8Array(this.blockSize); + } + + static async isSupported(algo) { + const { keySize } = getCipher(algo); + return webCrypto.importKey('raw', new Uint8Array(keySize), 'aes-cbc', false, ['encrypt']) + .then(() => true, () => false); + } + + async _runCBC(plaintext, nonZeroIV) { + const mode = 'AES-CBC'; + this.keyRef = this.keyRef || await webCrypto.importKey('raw', this.key, mode, false, ['encrypt']); + const ciphertext = await webCrypto.encrypt( + { name: mode, iv: nonZeroIV || this.zeroBlock }, + this.keyRef, + plaintext + ); + return new Uint8Array(ciphertext).subarray(0, plaintext.length); + } + + async encryptChunk(value) { + const missing = this.nextBlock.length - this.i; + const added = value.subarray(0, missing); + this.nextBlock.set(added, this.i); + if ((this.i + value.length) >= (2 * this.blockSize)) { + const leftover = (value.length - missing) % this.blockSize; + const plaintext = util.concatUint8Array([ + this.nextBlock, + value.subarray(missing, value.length - leftover) + ]); + const toEncrypt = util.concatUint8Array([ + this.prevBlock, + plaintext.subarray(0, plaintext.length - this.blockSize) // stop one block "early", since we only need to xor the plaintext and pass it over as prevBlock + ]); + + const encryptedBlocks = await this._runCBC(toEncrypt); + xorMut(encryptedBlocks, plaintext); + this.prevBlock = encryptedBlocks.subarray(-this.blockSize).slice(); + + // take care of leftover data + if (leftover > 0) this.nextBlock.set(value.subarray(-leftover).slice()); + this.i = leftover; + + return encryptedBlocks; + } + + this.i += added.length; + let encryptedBlock = new Uint8Array(); + if (this.i === this.nextBlock.length) { // block ready to be encrypted + const curBlock = this.nextBlock; + encryptedBlock = await this._runCBC(this.prevBlock); + xorMut(encryptedBlock, curBlock); + this.prevBlock = encryptedBlock.slice(); + this.i = 0; + + const remaining = value.subarray(added.length); + this.nextBlock.set(remaining, this.i); + this.i += remaining.length; + } + + return encryptedBlock; + } + + async finish() { + let result; + if (this.i === 0) { // nothing more to encrypt + result = new Uint8Array(); + } else { + this.nextBlock = this.nextBlock.subarray(0, this.i); + const curBlock = this.nextBlock; + const encryptedBlock = await this._runCBC(this.prevBlock); + xorMut(encryptedBlock, curBlock); + result = encryptedBlock.subarray(0, curBlock.length); + } + + this.clearSensitiveData(); + return result; + } + + clearSensitiveData() { + this.nextBlock.fill(0); + this.prevBlock.fill(0); + this.keyRef = null; + this.key = null; + } + + async encrypt(plaintext) { + // plaintext is internally padded to block length before encryption + const encryptedWithPadding = await this._runCBC( + util.concatUint8Array([new Uint8Array(this.blockSize), plaintext]), + this.iv + ); + // drop encrypted padding + const ct = encryptedWithPadding.subarray(0, plaintext.length); + xorMut(ct, plaintext); + this.clearSensitiveData(); + return ct; + } +} + +async function aesEncrypt(algo, key, pt, iv) { + if (webCrypto && await WebCryptoEncryptor.isSupported(algo)) { // Chromium does not implement AES with 192-bit keys + const cfb = new WebCryptoEncryptor(algo, key, iv); + return util.isStream(pt) ? stream.transform(pt, value => cfb.encryptChunk(value), () => cfb.finish()) : cfb.encrypt(pt); + } else { + const cfb = new AES_CFB(key, iv); + return stream.transform(pt, value => cfb.aes.AES_Encrypt_process(value), () => cfb.aes.AES_Encrypt_finish()); } - // asm.js fallback - const cfb = new AES_CFB(key, iv); - return stream.transform(pt, value => cfb.aes.AES_Encrypt_process(value), () => cfb.aes.AES_Encrypt_finish()); } function aesDecrypt(algo, key, ct, iv) { @@ -152,21 +256,12 @@ function aesDecrypt(algo, key, ct, iv) { } function xorMut(a, b) { - for (let i = 0; i < a.length; i++) { + const aLength = Math.min(a.length, b.length); + for (let i = 0; i < aLength; i++) { a[i] = a[i] ^ b[i]; } } -async function webEncrypt(algo, key, pt, iv) { - const ALGO = 'AES-CBC'; - const _key = await webCrypto.importKey('raw', key, { name: ALGO }, false, ['encrypt']); - const { blockSize } = getCipher(algo); - const cbc_pt = util.concatUint8Array([new Uint8Array(blockSize), pt]); - const ct = new Uint8Array(await webCrypto.encrypt({ name: ALGO, iv }, _key, cbc_pt)).subarray(0, pt.length); - xorMut(ct, pt); - return ct; -} - function nodeEncrypt(algo, key, pt, iv) { const algoName = enums.read(enums.symmetric, algo); const cipherObj = new nodeCrypto.createCipheriv(nodeAlgos[algoName], key, iv); diff --git a/src/crypto/mode/eax.js b/src/crypto/mode/eax.js index 433d4485..ab64ec6b 100644 --- a/src/crypto/mode/eax.js +++ b/src/crypto/mode/eax.js @@ -47,16 +47,6 @@ async function OMAC(key) { } async function CTR(key) { - if ( - util.getWebCrypto() && - key.length !== 24 // WebCrypto (no 192 bit support) see: https://www.chromium.org/blink/webcrypto#TOC-AES-support - ) { - key = await webCrypto.importKey('raw', key, { name: 'AES-CTR', length: key.length * 8 }, false, ['encrypt']); - return async function(pt, iv) { - const ct = await webCrypto.encrypt({ name: 'AES-CTR', counter: iv, length: blockLength * 8 }, key, pt); - return new Uint8Array(ct); - }; - } if (util.getNodeCrypto()) { // Node crypto library return async function(pt, iv) { const en = new nodeCrypto.createCipheriv('aes-' + (key.length * 8) + '-ctr', key, iv); @@ -64,6 +54,24 @@ async function CTR(key) { return new Uint8Array(ct); }; } + + if (util.getWebCrypto()) { + try { + const keyRef = await webCrypto.importKey('raw', key, { name: 'AES-CTR', length: key.length * 8 }, false, ['encrypt']); + return async function(pt, iv) { + const ct = await webCrypto.encrypt({ name: 'AES-CTR', counter: iv, length: blockLength * 8 }, keyRef, pt); + return new Uint8Array(ct); + }; + } catch (err) { + // no 192 bit support in Chromium, which throws `OperationError`, see: https://www.chromium.org/blink/webcrypto#TOC-AES-support + if (err.name !== 'NotSupportedError' && + !(key.length === 24 && err.name === 'OperationError')) { + throw err; + } + util.printDebugError('Browser did not support operation: ' + err.message); + } + } + // asm.js fallback return async function(pt, iv) { return AES_CTR.encrypt(pt, key, iv); diff --git a/src/crypto/mode/gcm.js b/src/crypto/mode/gcm.js index 0088b58b..a3cbd941 100644 --- a/src/crypto/mode/gcm.js +++ b/src/crypto/mode/gcm.js @@ -65,26 +65,37 @@ async function GCM(cipher, key) { }; } - if (util.getWebCrypto() && key.length !== 24) { // WebCrypto (no 192 bit support) see: https://www.chromium.org/blink/webcrypto#TOC-AES-support - const _key = await webCrypto.importKey('raw', key, { name: ALGO }, false, ['encrypt', 'decrypt']); + if (util.getWebCrypto()) { + try { + const _key = await webCrypto.importKey('raw', key, { name: ALGO }, false, ['encrypt', 'decrypt']); + // Safari 13 and Safari iOS 14 does not support GCM-en/decrypting empty messages + const webcryptoEmptyMessagesUnsupported = navigator.userAgent.match(/Version\/13\.\d(\.\d)* Safari/) || + navigator.userAgent.match(/Version\/(13|14)\.\d(\.\d)* Mobile\/\S* Safari/); + return { + encrypt: async function(pt, iv, adata = new Uint8Array()) { + if (webcryptoEmptyMessagesUnsupported && !pt.length) { + return AES_GCM.encrypt(pt, key, iv, adata); + } + const ct = await webCrypto.encrypt({ name: ALGO, iv, additionalData: adata, tagLength: tagLength * 8 }, _key, pt); + return new Uint8Array(ct); + }, - return { - encrypt: async function(pt, iv, adata = new Uint8Array()) { - if (!pt.length) { // iOS does not support GCM-en/decrypting empty messages - return AES_GCM.encrypt(pt, key, iv, adata); + decrypt: async function(ct, iv, adata = new Uint8Array()) { + if (webcryptoEmptyMessagesUnsupported && ct.length === tagLength) { + return AES_GCM.decrypt(ct, key, iv, adata); + } + const pt = await webCrypto.decrypt({ name: ALGO, iv, additionalData: adata, tagLength: tagLength * 8 }, _key, ct); + return new Uint8Array(pt); } - const ct = await webCrypto.encrypt({ name: ALGO, iv, additionalData: adata, tagLength: tagLength * 8 }, _key, pt); - return new Uint8Array(ct); - }, - - decrypt: async function(ct, iv, adata = new Uint8Array()) { - if (ct.length === tagLength) { // iOS does not support GCM-en/decrypting empty messages - return AES_GCM.decrypt(ct, key, iv, adata); - } - const pt = await webCrypto.decrypt({ name: ALGO, iv, additionalData: adata, tagLength: tagLength * 8 }, _key, ct); - return new Uint8Array(pt); + }; + } catch (err) { + // no 192 bit support in Chromium, which throws `OperationError`, see: https://www.chromium.org/blink/webcrypto#TOC-AES-support + if (err.name !== 'NotSupportedError' && + !(key.length === 24 && err.name === 'OperationError')) { + throw err; } - }; + util.printDebugError('Browser did not support operation: ' + err.message); + } } return {