Skip to content

Commit 4ef8b1f

Browse files
authored
fix: use StringDecoder for cipher string encoding remainder buffering (#949)
1 parent 2f1bdb2 commit 4ef8b1f

3 files changed

Lines changed: 71 additions & 3 deletions

File tree

example/src/tests/cipher/cipher_tests.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,59 @@ test(SUITE, 'Buffer concat vs string concat produce same result', () => {
190190
expect(bufResult).to.equal(strResult);
191191
});
192192

193+
test(SUITE, 'base64 string encoding with multi-block plaintext', () => {
194+
const testKey = Buffer.from(
195+
'KTnGEDonslhj/qGvf6rj4HSnO32T7dvjAs5PntTDB0s=',
196+
'base64',
197+
);
198+
const testIv = Buffer.from('2pXx2krk1wU8RI6AQjuPUg==', 'base64');
199+
// 32 bytes = 2 AES blocks; update() returns 32 bytes, 32 % 3 = 2 remainder
200+
const text = 'A'.repeat(32);
201+
202+
const cipher1 = createCipheriv('aes-256-cbc', testKey, testIv);
203+
const bufResult = Buffer.concat([
204+
cipher1.update(Buffer.from(text, 'utf8')),
205+
cipher1.final(),
206+
]).toString('base64');
207+
208+
const cipher2 = createCipheriv('aes-256-cbc', testKey, testIv);
209+
const strResult =
210+
cipher2.update(text, 'utf8', 'base64') + cipher2.final('base64');
211+
212+
expect(bufResult).to.equal(strResult);
213+
});
214+
215+
test(SUITE, 'base64 encoding at exactly one block boundary', () => {
216+
// 16 bytes = exactly one AES block; update() returns 16 bytes, 16 % 3 = 1
217+
const text = 'A'.repeat(16);
218+
219+
const cipher1 = createCipheriv('aes-128-cbc', key16, iv);
220+
const bufResult = Buffer.concat([
221+
cipher1.update(Buffer.from(text, 'utf8')),
222+
cipher1.final(),
223+
]).toString('base64');
224+
225+
const cipher2 = createCipheriv('aes-128-cbc', key16, iv);
226+
const strResult =
227+
cipher2.update(text, 'utf8', 'base64') + cipher2.final('base64');
228+
229+
expect(bufResult).to.equal(strResult);
230+
});
231+
232+
test(SUITE, 'base64 encoding encrypt/decrypt roundtrip with long input', () => {
233+
const longText = 'The quick brown fox jumps over the lazy dog. '.repeat(5);
234+
235+
const cipher = createCipheriv('aes-256-cbc', key32, iv);
236+
const encrypted =
237+
cipher.update(longText, 'utf8', 'base64') + cipher.final('base64');
238+
239+
const decipher = createDecipheriv('aes-256-cbc', key32, iv);
240+
const decrypted =
241+
decipher.update(encrypted, 'base64', 'utf8') + decipher.final('utf8');
242+
243+
expect(decrypted).to.equal(longText);
244+
});
245+
193246
test(SUITE, 'update with hex input and output encoding', () => {
194247
const cipher1 = createCipheriv('aes-128-cbc', key16, iv);
195248
const bufResult = Buffer.concat([

packages/react-native-quick-crypto/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"events": "3.3.0",
7979
"readable-stream": "4.5.2",
8080
"safe-buffer": "^5.2.1",
81+
"string_decoder": "^1.3.0",
8182
"util": "0.12.5"
8283
},
8384
"devDependencies": {

packages/react-native-quick-crypto/src/cipher.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NitroModules } from 'react-native-nitro-modules';
22
import Stream, { type TransformOptions } from 'readable-stream';
3+
import { StringDecoder } from 'string_decoder';
34
import { Buffer } from '@craftzdog/react-native-buffer';
45
import type { BinaryLike, BinaryLikeNode, Encoding } from './utils';
56
import type {
@@ -14,7 +15,7 @@ import type {
1415
Cipher as NativeCipher,
1516
CipherFactory,
1617
} from './specs/cipher.nitro';
17-
import { ab2str, binaryLikeToArrayBuffer } from './utils';
18+
import { binaryLikeToArrayBuffer } from './utils';
1819
import {
1920
getDefaultEncoding,
2021
getUIntOption,
@@ -74,6 +75,8 @@ interface CipherArgs {
7475

7576
class CipherCommon extends Stream.Transform {
7677
private native: NativeCipher;
78+
private _decoder: StringDecoder | null = null;
79+
private _decoderEncoding: string | undefined = undefined;
7780

7881
constructor({ isCipher, cipherType, cipherKey, iv, options }: CipherArgs) {
7982
// Explicitly create TransformOptions for super()
@@ -120,6 +123,17 @@ class CipherCommon extends Stream.Transform {
120123
});
121124
}
122125

126+
private getDecoder(encoding: string): StringDecoder {
127+
const normalized = normalizeEncoding(encoding);
128+
if (!this._decoder) {
129+
this._decoder = new StringDecoder(encoding as BufferEncoding);
130+
this._decoderEncoding = normalized;
131+
} else if (this._decoderEncoding !== normalized) {
132+
throw new Error('Cannot change encoding');
133+
}
134+
return this._decoder;
135+
}
136+
123137
update(data: Buffer): Buffer;
124138
update(data: BinaryLike, inputEncoding?: Encoding): Buffer;
125139
update(
@@ -147,7 +161,7 @@ class CipherCommon extends Stream.Transform {
147161
);
148162

149163
if (outputEncoding && outputEncoding !== 'buffer') {
150-
return ab2str(ret, outputEncoding);
164+
return this.getDecoder(outputEncoding).write(Buffer.from(ret));
151165
}
152166

153167
return Buffer.from(ret);
@@ -159,7 +173,7 @@ class CipherCommon extends Stream.Transform {
159173
const ret = this.native.final();
160174

161175
if (outputEncoding && outputEncoding !== 'buffer') {
162-
return ab2str(ret, outputEncoding);
176+
return this.getDecoder(outputEncoding).end(Buffer.from(ret));
163177
}
164178

165179
return Buffer.from(ret);

0 commit comments

Comments
 (0)