Skip to content

Commit 7035ced

Browse files
randaxØyvind Randaclaudesonnyp
authored
base64: Support multi bytes encoding characters (#1122)
* fix(base64): use TextEncoder/TextDecoder for proper UTF-8 encoding btoa()/atob() treat each character as a single Latin-1 byte, which silently corrupts non-ASCII characters in SASL credentials. For example, "ø" (U+00F8) gets encoded as byte 0xF8 instead of the correct UTF-8 sequence 0xC3 0xB8, causing XMPP authentication failures for users with non-ASCII usernames (Nordic æøå, German umlauts, Cyrillic, CJK, etc.). Use TextEncoder/TextDecoder to properly handle UTF-8 encoding and decoding before passing to btoa()/atob(). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * slightly modernize impl --------- Co-authored-by: Øyvind Randa <oyvind.randa@nextgentel.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Sonny Piers <sonny@fastmail.net>
1 parent d9facdc commit 7035ced

3 files changed

Lines changed: 138 additions & 15 deletions

File tree

package-lock.json

Lines changed: 1 addition & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/base64/index.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
export function encode(string) {
2-
return globalThis.btoa(string);
2+
const encoder = new TextEncoder();
3+
const bytes = encoder.encode(string);
4+
5+
if (typeof bytes.toBase64 === "function") {
6+
return bytes.toBase64();
7+
}
8+
9+
let binary = "";
10+
for (const byte of bytes) {
11+
binary += String.fromCodePoint(byte);
12+
}
13+
return globalThis.btoa(binary);
314
}
415

5-
export function decode(string) {
6-
return globalThis.atob(string);
16+
export function decode(data) {
17+
const decoder = new TextDecoder();
18+
19+
if (typeof Uint8Array.fromBase64 === "function") {
20+
return decoder.decode(Uint8Array.fromBase64(data));
21+
}
22+
23+
const binary = globalThis.atob(data);
24+
const bytes = Uint8Array.from(binary, (c) => c.codePointAt(0));
25+
return decoder.decode(bytes);
726
}

packages/base64/test/test.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { encode, decode } from "../index.js";
2+
3+
test("encodes ASCII strings", () => {
4+
expect(encode("hello")).toBe("aGVsbG8=");
5+
});
6+
7+
test("decodes ASCII strings", () => {
8+
expect(decode("aGVsbG8=")).toBe("hello");
9+
});
10+
11+
test("round-trips ASCII", () => {
12+
const input = "foo\0bar\0baz";
13+
expect(decode(encode(input))).toBe(input);
14+
});
15+
16+
test("round-trips null bytes in SASL PLAIN format", () => {
17+
const input = "\0username\0password";
18+
expect(decode(encode(input))).toBe(input);
19+
});
20+
21+
// Nordic characters (æ, ø, å)
22+
test("round-trips Nordic characters æøå", () => {
23+
expect(decode(encode("æ"))).toBe("æ");
24+
expect(decode(encode("ø"))).toBe("ø");
25+
expect(decode(encode("å"))).toBe("å");
26+
expect(decode(encode("Æ"))).toBe("Æ");
27+
expect(decode(encode("Ø"))).toBe("Ø");
28+
expect(decode(encode("Å"))).toBe("Å");
29+
});
30+
31+
// German umlauts
32+
test("round-trips German umlauts äöüß", () => {
33+
expect(decode(encode("ä"))).toBe("ä");
34+
expect(decode(encode("ö"))).toBe("ö");
35+
expect(decode(encode("ü"))).toBe("ü");
36+
expect(decode(encode("ß"))).toBe("ß");
37+
});
38+
39+
// French accented characters
40+
test("round-trips French accented characters", () => {
41+
expect(decode(encode("é"))).toBe("é");
42+
expect(decode(encode("è"))).toBe("è");
43+
expect(decode(encode("ê"))).toBe("ê");
44+
expect(decode(encode("ë"))).toBe("ë");
45+
expect(decode(encode("ç"))).toBe("ç");
46+
expect(decode(encode("ñ"))).toBe("ñ");
47+
});
48+
49+
// Cyrillic
50+
test("round-trips Cyrillic characters", () => {
51+
const input = "Привет";
52+
expect(decode(encode(input))).toBe(input);
53+
});
54+
55+
// CJK characters
56+
test("round-trips CJK characters", () => {
57+
expect(decode(encode("日本語"))).toBe("日本語");
58+
expect(decode(encode("中文"))).toBe("中文");
59+
expect(decode(encode("한국어"))).toBe("한국어");
60+
});
61+
62+
// Arabic and Hebrew
63+
test("round-trips Arabic and Hebrew characters", () => {
64+
expect(decode(encode("مرحبا"))).toBe("مرحبا");
65+
expect(decode(encode("שלום"))).toBe("שלום");
66+
});
67+
68+
// Emoji (4-byte UTF-8 sequences)
69+
test("round-trips emoji", () => {
70+
expect(decode(encode("🎉"))).toBe("🎉");
71+
expect(decode(encode("👨‍💻"))).toBe("👨‍💻");
72+
expect(decode(encode("🇳🇴"))).toBe("🇳🇴");
73+
});
74+
75+
// Mixed scripts
76+
test("round-trips mixed scripts in a single string", () => {
77+
const input = "Hello æøå Привет 日本語 🎉";
78+
expect(decode(encode(input))).toBe(input);
79+
});
80+
81+
// Verifies UTF-8 byte encoding rather than Latin-1
82+
test("encodes ø as UTF-8 bytes, not Latin-1", () => {
83+
// ø (U+00F8) in UTF-8 is [0xC3, 0xB8] → base64 "w7g="
84+
// In Latin-1 (btoa) it would be [0xF8] → base64 "+A=="
85+
expect(encode("ø")).toBe("w7g=");
86+
});
87+
88+
test("encodes æ as UTF-8 bytes, not Latin-1", () => {
89+
// æ (U+00E6) in UTF-8 is [0xC3, 0xA6] → base64 "w6Y="
90+
// In Latin-1 (btoa) it would be [0xE6] → base64 "5g=="
91+
expect(encode("æ")).toBe("w6Y=");
92+
});
93+
94+
test("encodes å as UTF-8 bytes, not Latin-1", () => {
95+
// å (U+00E5) in UTF-8 is [0xC3, 0xA5] → base64 "w6U="
96+
// In Latin-1 (btoa) it would be [0xE5] → base64 "5Q=="
97+
expect(encode("å")).toBe("w6U=");
98+
});
99+
100+
// Full SASL PLAIN payload with non-ASCII username
101+
test("SASL PLAIN payload with Nordic username", () => {
102+
const payload = "\0øyvindranda@example.com\0session-token";
103+
const encoded = encode(payload);
104+
expect(decode(encoded)).toBe(payload);
105+
});
106+
107+
test("SASL PLAIN payload with German username", () => {
108+
const payload = "\0müller@example.com\0password";
109+
expect(decode(encode(payload))).toBe(payload);
110+
});
111+
112+
test("SASL PLAIN payload with Cyrillic username", () => {
113+
const payload = "\0иван@example.com\0password";
114+
expect(decode(encode(payload))).toBe(payload);
115+
});

0 commit comments

Comments
 (0)