From a8bb391c08eed375fcfc87c1cd743cac1645877e Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 17 Feb 2026 11:52:25 -0500 Subject: [PATCH 1/2] fix: use StringDecoder for cipher string encoding remainder buffering Restores StringDecoder usage (removed during Nitro rewrite in #573) to properly handle base64/utf8 remainder bytes across update() and final() calls. Without this, base64-encoding each chunk independently produces premature padding, causing string concat to differ from Buffer concat for inputs >= 16 bytes (one AES block). Fixes #945 --- example/src/tests/cipher/cipher_tests.ts | 53 +++++++++++++++++++ .../react-native-quick-crypto/package.json | 1 + .../react-native-quick-crypto/src/cipher.ts | 15 ++++-- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/example/src/tests/cipher/cipher_tests.ts b/example/src/tests/cipher/cipher_tests.ts index 1fd72a6c..d681efda 100644 --- a/example/src/tests/cipher/cipher_tests.ts +++ b/example/src/tests/cipher/cipher_tests.ts @@ -190,6 +190,59 @@ test(SUITE, 'Buffer concat vs string concat produce same result', () => { expect(bufResult).to.equal(strResult); }); +test(SUITE, 'base64 string encoding with multi-block plaintext', () => { + const testKey = Buffer.from( + 'KTnGEDonslhj/qGvf6rj4HSnO32T7dvjAs5PntTDB0s=', + 'base64', + ); + const testIv = Buffer.from('2pXx2krk1wU8RI6AQjuPUg==', 'base64'); + // 32 bytes = 2 AES blocks; update() returns 32 bytes, 32 % 3 = 2 remainder + const text = 'A'.repeat(32); + + const cipher1 = createCipheriv('aes-256-cbc', testKey, testIv); + const bufResult = Buffer.concat([ + cipher1.update(Buffer.from(text, 'utf8')), + cipher1.final(), + ]).toString('base64'); + + const cipher2 = createCipheriv('aes-256-cbc', testKey, testIv); + const strResult = + cipher2.update(text, 'utf8', 'base64') + cipher2.final('base64'); + + expect(bufResult).to.equal(strResult); +}); + +test(SUITE, 'base64 encoding at exactly one block boundary', () => { + // 16 bytes = exactly one AES block; update() returns 16 bytes, 16 % 3 = 1 + const text = 'A'.repeat(16); + + const cipher1 = createCipheriv('aes-128-cbc', key16, iv); + const bufResult = Buffer.concat([ + cipher1.update(Buffer.from(text, 'utf8')), + cipher1.final(), + ]).toString('base64'); + + const cipher2 = createCipheriv('aes-128-cbc', key16, iv); + const strResult = + cipher2.update(text, 'utf8', 'base64') + cipher2.final('base64'); + + expect(bufResult).to.equal(strResult); +}); + +test(SUITE, 'base64 encoding encrypt/decrypt roundtrip with long input', () => { + const longText = 'The quick brown fox jumps over the lazy dog. '.repeat(5); + + const cipher = createCipheriv('aes-256-cbc', key32, iv); + const encrypted = + cipher.update(longText, 'utf8', 'base64') + cipher.final('base64'); + + const decipher = createDecipheriv('aes-256-cbc', key32, iv); + const decrypted = + decipher.update(encrypted, 'base64', 'utf8') + decipher.final('utf8'); + + expect(decrypted).to.equal(longText); +}); + test(SUITE, 'update with hex input and output encoding', () => { const cipher1 = createCipheriv('aes-128-cbc', key16, iv); const bufResult = Buffer.concat([ diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index 098c332c..3a61dc30 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -78,6 +78,7 @@ "events": "3.3.0", "readable-stream": "4.5.2", "safe-buffer": "^5.2.1", + "string_decoder": "^1.3.0", "util": "0.12.5" }, "devDependencies": { diff --git a/packages/react-native-quick-crypto/src/cipher.ts b/packages/react-native-quick-crypto/src/cipher.ts index d896ea65..59230d27 100644 --- a/packages/react-native-quick-crypto/src/cipher.ts +++ b/packages/react-native-quick-crypto/src/cipher.ts @@ -1,5 +1,6 @@ import { NitroModules } from 'react-native-nitro-modules'; import Stream, { type TransformOptions } from 'readable-stream'; +import { StringDecoder } from 'string_decoder'; import { Buffer } from '@craftzdog/react-native-buffer'; import type { BinaryLike, BinaryLikeNode, Encoding } from './utils'; import type { @@ -14,7 +15,7 @@ import type { Cipher as NativeCipher, CipherFactory, } from './specs/cipher.nitro'; -import { ab2str, binaryLikeToArrayBuffer } from './utils'; +import { binaryLikeToArrayBuffer } from './utils'; import { getDefaultEncoding, getUIntOption, @@ -74,6 +75,7 @@ interface CipherArgs { class CipherCommon extends Stream.Transform { private native: NativeCipher; + private _decoder: StringDecoder | null = null; constructor({ isCipher, cipherType, cipherKey, iv, options }: CipherArgs) { // Explicitly create TransformOptions for super() @@ -120,6 +122,13 @@ class CipherCommon extends Stream.Transform { }); } + private getDecoder(encoding: string): StringDecoder { + if (!this._decoder) { + this._decoder = new StringDecoder(encoding as BufferEncoding); + } + return this._decoder; + } + update(data: Buffer): Buffer; update(data: BinaryLike, inputEncoding?: Encoding): Buffer; update( @@ -147,7 +156,7 @@ class CipherCommon extends Stream.Transform { ); if (outputEncoding && outputEncoding !== 'buffer') { - return ab2str(ret, outputEncoding); + return this.getDecoder(outputEncoding).write(Buffer.from(ret)); } return Buffer.from(ret); @@ -159,7 +168,7 @@ class CipherCommon extends Stream.Transform { const ret = this.native.final(); if (outputEncoding && outputEncoding !== 'buffer') { - return ab2str(ret, outputEncoding); + return this.getDecoder(outputEncoding).end(Buffer.from(ret)); } return Buffer.from(ret); From be889926e6892cc709dc7c8026a5f9b3addf271c Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Tue, 17 Feb 2026 12:00:12 -0500 Subject: [PATCH 2/2] fix: validate encoding consistency in cipher StringDecoder Prevent silent data corruption when different output encodings are passed to update() and final(). Matches Node.js cipher behavior which asserts the encoding cannot change once a StringDecoder is initialized. --- packages/react-native-quick-crypto/src/cipher.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/react-native-quick-crypto/src/cipher.ts b/packages/react-native-quick-crypto/src/cipher.ts index 59230d27..0abb86bb 100644 --- a/packages/react-native-quick-crypto/src/cipher.ts +++ b/packages/react-native-quick-crypto/src/cipher.ts @@ -76,6 +76,7 @@ interface CipherArgs { class CipherCommon extends Stream.Transform { private native: NativeCipher; private _decoder: StringDecoder | null = null; + private _decoderEncoding: string | undefined = undefined; constructor({ isCipher, cipherType, cipherKey, iv, options }: CipherArgs) { // Explicitly create TransformOptions for super() @@ -123,8 +124,12 @@ class CipherCommon extends Stream.Transform { } private getDecoder(encoding: string): StringDecoder { + const normalized = normalizeEncoding(encoding); if (!this._decoder) { this._decoder = new StringDecoder(encoding as BufferEncoding); + this._decoderEncoding = normalized; + } else if (this._decoderEncoding !== normalized) { + throw new Error('Cannot change encoding'); } return this._decoder; }