Skip to content

Commit 9c211b8

Browse files
committed
fix: correct latin1/ascii decoding and harden base64 bounds
The latin1/binary and ascii stringToBuffer paths operated on raw UTF-8 bytes instead of decoded code points, producing wrong output for chars above 0x7F. Add proper UTF-8 decoding (decodeLatin1) so each code point maps to a single byte, matching Node.js Buffer.from(str, 'latin1'). Also adds INT_MAX bounds checks before size_t→int casts in the base64 encode/decode paths, moves benchmark-only _old helpers out of the package's public exports, and adds 28 encoding round-trip tests covering hex, base64, base64url, utf-8, latin1, and ascii.
1 parent f309da0 commit 9c211b8

5 files changed

Lines changed: 279 additions & 28 deletions

File tree

example/src/benchmarks/encoding/encoding.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
1-
import {
2-
bufferToString,
3-
stringToBuffer,
4-
ab2str_old,
5-
stringToBuffer_old,
6-
} from 'react-native-quick-crypto';
1+
import { bufferToString, stringToBuffer } from 'react-native-quick-crypto';
2+
import { Buffer as CraftzdogBuffer } from '@craftzdog/react-native-buffer';
73
import type { BenchFn } from '../../types/benchmarks';
84
import { Bench } from 'tinybench';
95

6+
function ab2str_old(buf: ArrayBuffer, encoding: string = 'hex'): string {
7+
return CraftzdogBuffer.from(buf).toString(encoding);
8+
}
9+
10+
function stringToBuffer_old(
11+
input: string,
12+
encoding: string = 'utf-8',
13+
): ArrayBuffer {
14+
const buffer = CraftzdogBuffer.from(input, encoding);
15+
return buffer.buffer.slice(
16+
buffer.byteOffset,
17+
buffer.byteOffset + buffer.byteLength,
18+
);
19+
}
20+
1021
// Generate test data
1122
const generate1MB = (): ArrayBuffer => {
1223
const bytes = new Uint8Array(1024 * 1024);

example/src/hooks/useTestsList.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import '../tests/subtle/supports';
4444
import '../tests/subtle/getPublicKey';
4545
import '../tests/subtle/wrap_unwrap';
4646
import '../tests/utils/utils_tests';
47+
import '../tests/utils/encoding_tests';
4748
import '../tests/x509/x509_tests';
4849

4950
export const useTestsList = (): [
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { bufferToString, stringToBuffer } from 'react-native-quick-crypto';
2+
import { expect } from 'chai';
3+
import { test } from '../util';
4+
5+
const SUITE = 'utils';
6+
7+
// --- Helper ---
8+
9+
const toU8 = (ab: ArrayBuffer): Uint8Array => new Uint8Array(ab);
10+
11+
// --- Hex ---
12+
13+
test(SUITE, 'hex encode empty buffer', () => {
14+
const ab = new ArrayBuffer(0);
15+
expect(bufferToString(ab, 'hex')).to.equal('');
16+
});
17+
18+
test(SUITE, 'hex decode empty string', () => {
19+
expect(toU8(stringToBuffer('', 'hex'))).to.deep.equal(new Uint8Array([]));
20+
});
21+
22+
test(SUITE, 'hex encode known bytes', () => {
23+
const ab = new Uint8Array([0xde, 0xad, 0xbe, 0xef]).buffer as ArrayBuffer;
24+
expect(bufferToString(ab, 'hex')).to.equal('deadbeef');
25+
});
26+
27+
test(SUITE, 'hex decode known string', () => {
28+
expect(toU8(stringToBuffer('deadbeef', 'hex'))).to.deep.equal(
29+
new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
30+
);
31+
});
32+
33+
test(SUITE, 'hex roundtrip all byte values', () => {
34+
const bytes = new Uint8Array(256);
35+
for (let i = 0; i < 256; i++) bytes[i] = i;
36+
const ab = bytes.buffer as ArrayBuffer;
37+
const hex = bufferToString(ab, 'hex');
38+
expect(hex.length).to.equal(512);
39+
expect(toU8(stringToBuffer(hex, 'hex'))).to.deep.equal(bytes);
40+
});
41+
42+
test(SUITE, 'hex decode is case-insensitive', () => {
43+
const lower = toU8(stringToBuffer('abcdef', 'hex'));
44+
const upper = toU8(stringToBuffer('ABCDEF', 'hex'));
45+
expect(lower).to.deep.equal(upper);
46+
});
47+
48+
test(SUITE, 'hex decode rejects odd-length string', () => {
49+
expect(() => stringToBuffer('abc', 'hex')).to.throw();
50+
});
51+
52+
test(SUITE, 'hex decode rejects invalid characters', () => {
53+
expect(() => stringToBuffer('zzzz', 'hex')).to.throw();
54+
});
55+
56+
// --- Base64 ---
57+
58+
test(SUITE, 'base64 encode empty buffer', () => {
59+
const ab = new ArrayBuffer(0);
60+
expect(bufferToString(ab, 'base64')).to.equal('');
61+
});
62+
63+
test(SUITE, 'base64 decode empty string', () => {
64+
expect(toU8(stringToBuffer('', 'base64'))).to.deep.equal(new Uint8Array([]));
65+
});
66+
67+
test(SUITE, 'base64 encode/decode RFC 4648 test vectors', () => {
68+
const vectors: [string, string][] = [
69+
['', ''],
70+
['f', 'Zg=='],
71+
['fo', 'Zm8='],
72+
['foo', 'Zm9v'],
73+
['foob', 'Zm9vYg=='],
74+
['fooba', 'Zm9vYmE='],
75+
['foobar', 'Zm9vYmFy'],
76+
];
77+
for (const [plain, encoded] of vectors) {
78+
const ab = new Uint8Array(plain.split('').map(c => c.charCodeAt(0)))
79+
.buffer as ArrayBuffer;
80+
expect(bufferToString(ab, 'base64')).to.equal(encoded);
81+
expect(toU8(stringToBuffer(encoded, 'base64'))).to.deep.equal(
82+
new Uint8Array(ab),
83+
);
84+
}
85+
});
86+
87+
test(SUITE, 'base64 roundtrip binary data', () => {
88+
const bytes = new Uint8Array([0, 1, 127, 128, 254, 255]);
89+
const ab = bytes.buffer as ArrayBuffer;
90+
const b64 = bufferToString(ab, 'base64');
91+
expect(toU8(stringToBuffer(b64, 'base64'))).to.deep.equal(bytes);
92+
});
93+
94+
// --- Base64url ---
95+
96+
test(SUITE, 'base64url encode produces URL-safe characters', () => {
97+
// Bytes that produce + and / in standard base64
98+
const bytes = new Uint8Array([0xfb, 0xff, 0xfe]);
99+
const ab = bytes.buffer as ArrayBuffer;
100+
const result = bufferToString(ab, 'base64url');
101+
expect(result).to.not.include('+');
102+
expect(result).to.not.include('/');
103+
expect(result).to.not.include('=');
104+
});
105+
106+
test(SUITE, 'base64url roundtrip', () => {
107+
const bytes = new Uint8Array([0xfb, 0xff, 0xfe, 0x00, 0x42]);
108+
const ab = bytes.buffer as ArrayBuffer;
109+
const encoded = bufferToString(ab, 'base64url');
110+
expect(toU8(stringToBuffer(encoded, 'base64url'))).to.deep.equal(bytes);
111+
});
112+
113+
// --- UTF-8 ---
114+
115+
test(SUITE, 'utf8 encode/decode ASCII', () => {
116+
const str = 'hello world';
117+
const ab = stringToBuffer(str, 'utf-8');
118+
expect(bufferToString(ab, 'utf-8')).to.equal(str);
119+
});
120+
121+
test(SUITE, 'utf8 encode/decode multibyte', () => {
122+
const str = '\u00e9\u00fc\u00f1'; // éüñ
123+
const ab = stringToBuffer(str, 'utf-8');
124+
expect(bufferToString(ab, 'utf-8')).to.equal(str);
125+
});
126+
127+
test(SUITE, 'utf8 alias "utf8" works', () => {
128+
const str = 'test';
129+
const ab = stringToBuffer(str, 'utf8');
130+
expect(bufferToString(ab, 'utf8')).to.equal(str);
131+
});
132+
133+
// --- Latin1 / Binary ---
134+
135+
test(
136+
SUITE,
137+
'latin1 encode: bytes 0x80-0xFF produce correct UTF-8 strings',
138+
() => {
139+
const bytes = new Uint8Array([0xe9, 0xfc, 0xf1]); // é, ü, ñ in Latin-1
140+
const ab = bytes.buffer as ArrayBuffer;
141+
const str = bufferToString(ab, 'latin1');
142+
expect(str).to.equal('\u00e9\u00fc\u00f1');
143+
},
144+
);
145+
146+
test(
147+
SUITE,
148+
'latin1 decode: UTF-8 string maps each code point to one byte',
149+
() => {
150+
const str = '\u00e9\u00fc\u00f1'; // é, ü, ñ
151+
const ab = stringToBuffer(str, 'latin1');
152+
expect(toU8(ab)).to.deep.equal(new Uint8Array([0xe9, 0xfc, 0xf1]));
153+
},
154+
);
155+
156+
test(SUITE, 'latin1 roundtrip all byte values 0x00-0xFF', () => {
157+
const bytes = new Uint8Array(256);
158+
for (let i = 0; i < 256; i++) bytes[i] = i;
159+
const ab = bytes.buffer as ArrayBuffer;
160+
const str = bufferToString(ab, 'latin1');
161+
const roundtripped = toU8(stringToBuffer(str, 'latin1'));
162+
expect(roundtripped).to.deep.equal(bytes);
163+
});
164+
165+
test(SUITE, 'binary is alias for latin1 (encode)', () => {
166+
const bytes = new Uint8Array([0xca, 0xfe]);
167+
const ab = bytes.buffer as ArrayBuffer;
168+
expect(bufferToString(ab, 'binary')).to.equal(bufferToString(ab, 'latin1'));
169+
});
170+
171+
test(SUITE, 'binary is alias for latin1 (decode)', () => {
172+
const str = '\u00ca\u00fe';
173+
expect(toU8(stringToBuffer(str, 'binary'))).to.deep.equal(
174+
toU8(stringToBuffer(str, 'latin1')),
175+
);
176+
});
177+
178+
test(
179+
SUITE,
180+
'latin1 decode truncates code points above 0xFF to low byte',
181+
() => {
182+
// Node.js Buffer.from('\u0100', 'latin1') produces [0x00] (256 & 0xFF = 0)
183+
const ab = stringToBuffer('\u0100', 'latin1');
184+
expect(toU8(ab)).to.deep.equal(new Uint8Array([0x00]));
185+
},
186+
);
187+
188+
// --- ASCII ---
189+
190+
test(SUITE, 'ascii encode strips high bit', () => {
191+
const bytes = new Uint8Array([0x48, 0xc8]); // 'H', 0xC8
192+
const ab = bytes.buffer as ArrayBuffer;
193+
const str = bufferToString(ab, 'ascii');
194+
expect(str.charCodeAt(0)).to.equal(0x48);
195+
expect(str.charCodeAt(1)).to.equal(0x48); // 0xC8 & 0x7F = 0x48
196+
});
197+
198+
test(SUITE, 'ascii decode strips high bit', () => {
199+
const str = String.fromCharCode(0xc8); // above 0x7F
200+
const ab = stringToBuffer(str, 'ascii');
201+
expect(toU8(ab)[0]).to.equal(0x48); // 0xC8 & 0x7F = 0x48
202+
});
203+
204+
test(SUITE, 'ascii roundtrip printable ASCII', () => {
205+
const str = 'Hello, World! 123';
206+
const ab = stringToBuffer(str, 'ascii');
207+
expect(bufferToString(ab, 'ascii')).to.equal(str);
208+
});
209+
210+
// --- Unsupported encoding ---
211+
212+
test(SUITE, 'bufferToString throws for unsupported encoding', () => {
213+
const ab = new ArrayBuffer(1);
214+
expect(() => bufferToString(ab, 'ucs2')).to.throw();
215+
});
216+
217+
test(SUITE, 'stringToBuffer throws for unsupported encoding', () => {
218+
expect(() => stringToBuffer('test', 'ucs2')).to.throw();
219+
});

packages/react-native-quick-crypto/cpp/utils/HybridUtils.cpp

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include "HybridUtils.hpp"
22

3+
#include <limits>
34
#include <openssl/crypto.h>
45
#include <openssl/evp.h>
56
#include <stdexcept>
@@ -51,6 +52,9 @@ namespace {
5152
}
5253

5354
std::string encodeBase64(const uint8_t* data, size_t len) {
55+
if (len > static_cast<size_t>(std::numeric_limits<int>::max())) {
56+
throw std::runtime_error("Input too large for base64 encoding");
57+
}
5458
size_t encodedLen = ((len + 2) / 3) * 4;
5559
std::string result(encodedLen + 1, '\0');
5660
int written = EVP_EncodeBlock(reinterpret_cast<unsigned char*>(result.data()), data, static_cast<int>(len));
@@ -62,6 +66,9 @@ namespace {
6266
}
6367

6468
std::vector<uint8_t> decodeBase64(const std::string& b64) {
69+
if (b64.length() > static_cast<size_t>(std::numeric_limits<int>::max())) {
70+
throw std::runtime_error("Input too large for base64 decoding");
71+
}
6572
size_t maxLen = ((b64.length() + 3) / 4) * 3;
6673
std::vector<uint8_t> result(maxLen);
6774
int written = EVP_DecodeBlock(result.data(), reinterpret_cast<const unsigned char*>(b64.data()), static_cast<int>(b64.length()));
@@ -108,6 +115,35 @@ namespace {
108115
return decodeBase64(b64);
109116
}
110117

118+
std::vector<uint8_t> decodeLatin1(const std::string& str) {
119+
std::vector<uint8_t> result;
120+
result.reserve(str.size());
121+
size_t i = 0;
122+
while (i < str.size()) {
123+
auto byte = static_cast<uint8_t>(str[i]);
124+
uint32_t cp;
125+
if (byte < 0x80) {
126+
cp = byte;
127+
i += 1;
128+
} else if ((byte & 0xE0) == 0xC0 && i + 1 < str.size()) {
129+
cp = ((byte & 0x1F) << 6) | (static_cast<uint8_t>(str[i + 1]) & 0x3F);
130+
i += 2;
131+
} else if ((byte & 0xF0) == 0xE0 && i + 2 < str.size()) {
132+
cp = ((byte & 0x0F) << 12) | ((static_cast<uint8_t>(str[i + 1]) & 0x3F) << 6) | (static_cast<uint8_t>(str[i + 2]) & 0x3F);
133+
i += 3;
134+
} else if ((byte & 0xF8) == 0xF0 && i + 3 < str.size()) {
135+
cp = ((byte & 0x07) << 18) | ((static_cast<uint8_t>(str[i + 1]) & 0x3F) << 12) | ((static_cast<uint8_t>(str[i + 2]) & 0x3F) << 6) |
136+
(static_cast<uint8_t>(str[i + 3]) & 0x3F);
137+
i += 4;
138+
} else {
139+
cp = byte;
140+
i += 1;
141+
}
142+
result.push_back(static_cast<uint8_t>(cp & 0xFF));
143+
}
144+
return result;
145+
}
146+
111147
std::string encodeLatin1(const uint8_t* data, size_t len) {
112148
std::string result;
113149
result.reserve(len * 2);
@@ -183,14 +219,15 @@ std::shared_ptr<ArrayBuffer> HybridUtils::stringToBuffer(const std::string& str,
183219
return ToNativeArrayBuffer(str);
184220
}
185221
if (encoding == "latin1" || encoding == "binary") {
186-
return ToNativeArrayBuffer(str);
222+
auto decoded = decodeLatin1(str);
223+
return ToNativeArrayBuffer(decoded);
187224
}
188225
if (encoding == "ascii") {
189-
std::string ascii = str;
190-
for (auto& c : ascii) {
191-
c &= 0x7F;
226+
auto decoded = decodeLatin1(str);
227+
for (auto& b : decoded) {
228+
b &= 0x7F;
192229
}
193-
return ToNativeArrayBuffer(ascii);
230+
return ToNativeArrayBuffer(decoded);
194231
}
195232
throw std::runtime_error("Unsupported encoding: " + encoding);
196233
}

packages/react-native-quick-crypto/src/utils/conversion.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -189,23 +189,6 @@ export function stringToBuffer(
189189
return utils.stringToBuffer(str, encoding);
190190
}
191191

192-
/** Old Buffer-polyfill implementation — kept for benchmarking comparison */
193-
export function ab2str_old(buf: ArrayBuffer, encoding: string = 'hex'): string {
194-
return CraftzdogBuffer.from(buf).toString(encoding);
195-
}
196-
197-
/** Old Buffer-polyfill implementation — kept for benchmarking comparison */
198-
export function stringToBuffer_old(
199-
input: string,
200-
encoding: string = 'utf-8',
201-
): ArrayBuffer {
202-
const buffer = CraftzdogBuffer.from(input, encoding);
203-
return buffer.buffer.slice(
204-
buffer.byteOffset,
205-
buffer.byteOffset + buffer.byteLength,
206-
);
207-
}
208-
209192
export const kEmptyObject = Object.freeze(Object.create(null));
210193

211194
export * from './noble';

0 commit comments

Comments
 (0)