From fc619b746ce25b862d7510ad51231264516db798 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Tue, 24 Sep 2024 21:46:13 +0900 Subject: [PATCH] feat: Fix decryption of strings larger than 4 MiB (#411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fix decrypting strings larger than 4 MiB The `cloakedStringRegex` fails to parse ciphertexts larger than about 4 MiB. This is due to [limitations in V8's regex engine][1]. I've adapted the implementation from https://github.com/validatorjs/validator.js/pull/503 under the MIT license, as it solves the error. [1]: https://issues.chromium.org/issues/42207207 * feat: export `parseCloakedString` function This function can be used instead of `cloakedStringRegex` on large strings. * feat: deprecate `cloakedStringRegex` This regex fails on large 4 MiB + strings. `parseCloakedString` should be used instead when possible. * test: test decrypting strings larger than 4 MiB This fails in Node.JS v20.17.0 with @47ng/cloak v1.1.0. * chore: Make hex & base64 Regexes case-insensitive --------- Co-authored-by: François Best --- src/index.test.ts | 8 ++++++ src/message.ts | 66 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 73216fc..ad51dd3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -23,6 +23,14 @@ describe('v1 format', () => { expect(received).toEqual(expected) }) + test('Encrypt / decript 4 MiB string', async () => { + const key = 'k1.aesgcm256.2itF7YmMYIP4b9NNtKMhIx2axGi6aI50RcwGBiFq-VA=' + const expected = 'a'.repeat(4_194_304) // 2 ** 22 = 4 MiB + const cipher = await encryptString(expected, key) + const received = await decryptString(cipher, key) + expect(received).toEqual(expected) + }) + test('Encrypt empty string', async () => { const key = 'k1.aesgcm256.2itF7YmMYIP4b9NNtKMhIx2axGi6aI50RcwGBiFq-VA=' const expected = '' diff --git a/src/message.ts b/src/message.ts index e89afab..8b4be69 100644 --- a/src/message.ts +++ b/src/message.ts @@ -59,18 +59,72 @@ export function encryptStringSync( // Decryption -- +/** + * @deprecated + * + * Causes stack errors on large strings, use {@link parseCloakedString} instead. + */ export const cloakedStringRegex = /^v1\.aesgcm256\.(?[0-9a-fA-F]{8})\.(?[a-zA-Z0-9-_]{16})\.(?[a-zA-Z0-9-_]{22,})={0,2}$/ +/** + * Tests if the input string consists only of URL-safe Base64 chars + * (e.g. using `-` and `=` instead of `+` and `/`), and is padded with `=`. + * + * @returns `true` if the string is a valid URL-safe Base64, else `false`. + * + * Adapted from + * @license MIT + * @copyright Copyright (c) 2016 Chris O'Hara + */ +function isBase64(str: string) { + const len = str.length + if (len % 4 === 0 && !/(^[a-z0-9-_=])/i.test(str)) { + return false + } + const firstPaddingChar = str.indexOf('=') + return ( + firstPaddingChar === -1 || + firstPaddingChar === len - 1 || + (firstPaddingChar === len - 2 && str[len - 1] === '=') + ) +} + +export function parseCloakedString(input: CloakedString) { + const [version, algorithm, fingerprint, iv, ciphertext, nothing] = + input.split('.') + + const isCloakedString = + version === 'v1' && + algorithm === 'aesgcm256' && + /^[0-9a-f]{8}$/i.test(fingerprint) && + /^[a-zA-Z0-9-_]{16}$/.test(iv) && + isBase64(ciphertext) && + ciphertext.length >= 24 && + nothing === undefined + + if (isCloakedString === false) { + return false + } else { + return { + groups: { + fingerprint, + iv, + ciphertext + } + } + } +} + export async function decryptString( input: CloakedString, key: CloakKey | ParsedCloakKey ): Promise { - const match = input.match(cloakedStringRegex) + const match = parseCloakedString(input) if (!match) { throw new Error(`Unknown message format: ${input}`) } - const iv = match.groups!.iv + const iv = match.groups.iv const ciphertext = match.groups!.ciphertext let aesKey: CryptoKey | Uint8Array if (typeof key === 'string') { @@ -88,11 +142,11 @@ export function decryptStringSync( input: CloakedString, key: CloakKey | ParsedCloakKey ): string { - const match = input.match(cloakedStringRegex) + const match = parseCloakedString(input) if (!match) { throw new Error(`Unknown message format: ${input}`) } - const iv = match.groups!.iv + const iv = match.groups.iv const ciphertext = match.groups!.ciphertext let aesKey: CryptoKey | Uint8Array if (typeof key === 'string') { @@ -107,9 +161,9 @@ export function decryptStringSync( } export function getMessageKeyFingerprint(message: CloakedString) { - const match = message.match(cloakedStringRegex) + const match = parseCloakedString(message) if (!match) { throw new Error('Unknown message format') } - return match.groups!.fingerprint + return match.groups.fingerprint }