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..0abb86bb 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,8 @@ 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() @@ -120,6 +123,17 @@ 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; + } + update(data: Buffer): Buffer; update(data: BinaryLike, inputEncoding?: Encoding): Buffer; update( @@ -147,7 +161,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 +173,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);