UNPKG

@ctrl/ts-base32

Version:

Base32 encoder/decoder with support for multiple variants

263 lines (262 loc) 9.15 kB
/* eslint-disable no-bitwise */ const RFC4648 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const RFC4648_HEX = '0123456789ABCDEFGHIJKLMNOPQRSTUV'; const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // Pre-computed 2-char string table (32x32 = 1024 entries). Lets us emit 8 output // chars per 5-byte input chunk with 4 string concats instead of 8. This is the // fastest portable encode strategy — the only thing faster is Buffer.latin1Slice // which is Node-specific. function createEncodePairs(alphabet) { const pairs = []; for (let i = 0; i < 32; i++) { for (let j = 0; j < 32; j++) { pairs.push(alphabet[i] + alphabet[j]); } } return pairs; } // Single-char lookup for the trailing partial block (0-4 bytes) that doesn't // fill a complete 5-byte chunk. function createEncodeLookup(alphabet) { const table = new Uint8Array(32); for (let i = 0; i < 32; i++) { table[i] = alphabet.charCodeAt(i); } return table; } let rfc4648EncodePairs; let rfc4648HexEncodePairs; let crockfordEncodePairs; let rfc4648EncodeLookup; let rfc4648HexEncodeLookup; let crockfordEncodeLookup; function getEncodePairs(variant) { switch (variant) { case 'RFC3548': case 'RFC4648': { if (rfc4648EncodePairs === undefined) { rfc4648EncodePairs = createEncodePairs(RFC4648); } return rfc4648EncodePairs; } case 'RFC4648-HEX': { if (rfc4648HexEncodePairs === undefined) { rfc4648HexEncodePairs = createEncodePairs(RFC4648_HEX); } return rfc4648HexEncodePairs; } case 'Crockford': { if (crockfordEncodePairs === undefined) { crockfordEncodePairs = createEncodePairs(CROCKFORD); } return crockfordEncodePairs; } default: { throw new Error(`Unknown base32 variant: ${variant}`); } } } function getEncodeLookup(variant) { switch (variant) { case 'RFC3548': case 'RFC4648': { if (rfc4648EncodeLookup === undefined) { rfc4648EncodeLookup = createEncodeLookup(RFC4648); } return rfc4648EncodeLookup; } case 'RFC4648-HEX': { if (rfc4648HexEncodeLookup === undefined) { rfc4648HexEncodeLookup = createEncodeLookup(RFC4648_HEX); } return rfc4648HexEncodeLookup; } case 'Crockford': { if (crockfordEncodeLookup === undefined) { crockfordEncodeLookup = createEncodeLookup(CROCKFORD); } return crockfordEncodeLookup; } default: { throw new Error(`Unknown base32 variant: ${variant}`); } } } // Decode lookup: charCode → 5-bit value. Int8Array so -1 sentinel propagates // through bitwise OR, letting us batch-validate 4 lookups with a single sign check. // Includes lowercase mappings (a-z → same as A-Z) to avoid toUpperCase() calls. function createDecodeLookup(alphabet, crockfordAliases) { const table = new Int8Array(128); table.fill(-1); for (let i = 0; i < alphabet.length; i++) { const code = alphabet.charCodeAt(i); table[code] = i; if (code >= 65 && code <= 90) { table[code + 32] = i; } } // Crockford treats O as 0 and I/L as 1 — bake these aliases into the table // so we never need replace() calls on the input string. if (crockfordAliases) { table[79] = 0; // O table[111] = 0; // o table[73] = 1; // I table[105] = 1; // i table[76] = 1; // L table[108] = 1; // l } return table; } let rfc4648Lookup; let rfc4648HexLookup; let crockfordLookup; function getDecodeLookup(variant) { switch (variant) { case 'RFC3548': case 'RFC4648': { if (rfc4648Lookup === undefined) { rfc4648Lookup = createDecodeLookup(RFC4648, false); } return rfc4648Lookup; } case 'RFC4648-HEX': { if (rfc4648HexLookup === undefined) { rfc4648HexLookup = createDecodeLookup(RFC4648_HEX, false); } return rfc4648HexLookup; } case 'Crockford': { if (crockfordLookup === undefined) { crockfordLookup = createDecodeLookup(CROCKFORD, true); } return crockfordLookup; } default: { throw new Error(`Unknown base32 variant: ${variant}`); } } } // Indexed by trailing byte count (0-4) after full 5-byte chunks. const PADDING_CHARS = ['', '======', '====', '===', '=']; export function base32Encode(input, variant = 'RFC4648', options = {}) { const pairs = getEncodePairs(variant); const encodeLookup = getEncodeLookup(variant); const defaultPadding = variant !== 'Crockford'; const padding = options.padding ?? defaultPadding; const length = input.byteLength; const fullChunks = Math.floor(length / 5); const fullChunksBytes = fullChunks * 5; let o = ''; let i = 0; // Fast path: process 5 input bytes → 8 output chars using the pair table. // Each 5-byte chunk yields four 10-bit indices into the 1024-entry pairs array. for (; i < fullChunksBytes; i += 5) { const a = input[i]; const b = input[i + 1]; const c = input[i + 2]; const d = input[i + 3]; const e = input[i + 4]; const x0 = (a << 2) | (b >> 6); const x1 = ((b & 0x3f) << 4) | (c >> 4); const x2 = ((c & 0xf) << 6) | (d >> 2); const x3 = ((d & 0x3) << 8) | e; o += pairs[x0]; o += pairs[x1]; o += pairs[x2]; o += pairs[x3]; } // Slow path: remaining 1-4 bytes, emit one char at a time. const remaining = length - fullChunksBytes; if (remaining > 0) { let bits = 0; let value = 0; for (; i < length; i++) { value = (value << 8) | input[i]; bits += 8; while (bits >= 5) { o += String.fromCharCode(encodeLookup[(value >>> (bits - 5)) & 31]); bits -= 5; } } if (bits > 0) { o += String.fromCharCode(encodeLookup[(value << (5 - bits)) & 31]); } } if (padding) { o += PADDING_CHARS[remaining]; } return o; } function readChar(table, charCode) { const idx = charCode < 128 ? table[charCode] : -1; if (idx === -1) { throw new Error(`Invalid character found: ${String.fromCharCode(charCode)}`); } return idx; } export function base32Decode(input, variant = 'RFC4648') { const m = getDecodeLookup(variant); // Strip trailing '=' padding with a charCodeAt loop instead of replace(). let end = input.length; if (variant !== 'Crockford') { while (end > 0 && input.charCodeAt(end - 1) === 61) { end--; } } const tailLength = end % 8; const mainLength = end - tailLength; const output = new Uint8Array(Math.trunc((end * 5) / 8)); let at = 0; // Fast path: process 8 input chars → 5 output bytes. Packs two groups of 4 // lookups into 20-bit integers, then extracts 5 bytes. The Int8Array table // makes any -1 (invalid char) propagate through the OR chain, so a single // sign check covers all 4 lookups. for (let i = 0; i < mainLength; i += 8) { const x0 = input.charCodeAt(i); const x1 = input.charCodeAt(i + 1); const x2 = input.charCodeAt(i + 2); const x3 = input.charCodeAt(i + 3); const x4 = input.charCodeAt(i + 4); const x5 = input.charCodeAt(i + 5); const x6 = input.charCodeAt(i + 6); const x7 = input.charCodeAt(i + 7); const a = (m[x0] << 15) | (m[x1] << 10) | (m[x2] << 5) | m[x3]; const b = (m[x4] << 15) | (m[x5] << 10) | (m[x6] << 5) | m[x7]; if (a < 0 || b < 0) { for (let j = i; j < i + 8; j++) { readChar(m, input.charCodeAt(j)); } } output[at] = a >> 12; output[at + 1] = (a >> 4) & 0xff; output[at + 2] = ((a << 4) & 0xff) | (b >> 16); output[at + 3] = (b >> 8) & 0xff; output[at + 4] = b & 0xff; at += 5; } // Slow path: remaining 0-7 chars. let bits = 0; let value = 0; for (let i = mainLength; i < end; i++) { value = (value << 5) | readChar(m, input.charCodeAt(i)); bits += 5; if (bits >= 8) { output[at++] = (value >>> (bits - 8)) & 255; bits -= 8; } } return output; } /** * Turn a string of hexadecimal characters into an Uint8Array */ export function hexToUint8Array(hex) { if (hex.length % 2 !== 0) { throw new RangeError('Expected string to be an even number of characters'); } const view = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { view[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); } return view; }