otp/utils/base32.js
“xHuPo” 2b8870a40e init
2025-06-09 13:35:15 +08:00

336 lines
No EOL
8.9 KiB
JavaScript

/**
* Enhanced Base32 implementation compatible with RFC 4648
* Optimized for WeChat Mini Program environment
*/
// RFC 4648 standard Base32 alphabet and padding
const DEFAULT_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
const PADDING = '=';
// Pre-computed lookup tables
let ENCODE_TABLE = [...DEFAULT_ALPHABET];
let DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
// Valid padding lengths for Base32
const VALID_PADDING_LENGTHS = new Set([0, 1, 3, 4, 6]);
/**
* Custom error class for Base32 operations
*/
class Base32Error extends Error {
constructor(message, type = 'GENERAL_ERROR') {
super(message);
this.name = 'Base32Error';
this.type = type;
}
}
/**
* String to UTF-8 bytes conversion (WeChat Mini Program compatible)
* @private
*/
function stringToUint8Array(str) {
const arr = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
arr.push(code);
} else if (code < 0x800) {
arr.push(0xc0 | (code >> 6));
arr.push(0x80 | (code & 0x3f));
} else if (code < 0xd800 || code >= 0xe000) {
arr.push(0xe0 | (code >> 12));
arr.push(0x80 | ((code >> 6) & 0x3f));
arr.push(0x80 | (code & 0x3f));
} else {
// Handle surrogate pairs
i++;
code = 0x10000 + (((code & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
arr.push(0xf0 | (code >> 18));
arr.push(0x80 | ((code >> 12) & 0x3f));
arr.push(0x80 | ((code >> 6) & 0x3f));
arr.push(0x80 | (code & 0x3f));
}
}
return new Uint8Array(arr);
}
/**
* UTF-8 bytes to string conversion (WeChat Mini Program compatible)
* @private
*/
function uint8ArrayToString(bytes) {
let result = '';
for (let i = 0; i < bytes.length; i++) {
const byte = bytes[i];
if (byte < 0x80) {
result += String.fromCharCode(byte);
} else if (byte < 0xe0) {
result += String.fromCharCode(
((byte & 0x1f) << 6) |
(bytes[++i] & 0x3f)
);
} else if (byte < 0xf0) {
result += String.fromCharCode(
((byte & 0x0f) << 12) |
((bytes[++i] & 0x3f) << 6) |
(bytes[++i] & 0x3f)
);
} else {
const codePoint = (
((byte & 0x07) << 18) |
((bytes[++i] & 0x3f) << 12) |
((bytes[++i] & 0x3f) << 6) |
(bytes[++i] & 0x3f)
);
result += String.fromCodePoint(codePoint);
}
}
return result;
}
/**
* Sets a custom alphabet for Base32 encoding/decoding
* @param {string} alphabet - The custom Base32 alphabet (32 unique characters)
* @throws {Base32Error} If the alphabet is invalid
*/
function setCustomAlphabet(alphabet) {
if (typeof alphabet !== 'string' || alphabet.length !== 32 || new Set(alphabet).size !== 32) {
throw new Base32Error(
'Invalid custom alphabet: must be 32 unique characters',
'INVALID_ALPHABET'
);
}
ENCODE_TABLE = [...alphabet];
DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
}
/**
* Resets to the default Base32 alphabet
*/
function resetToDefaultAlphabet() {
ENCODE_TABLE = [...DEFAULT_ALPHABET];
DECODE_TABLE = new Map(ENCODE_TABLE.map((char, index) => [char, index]));
}
/**
* Validates padding length and position
* @private
*/
function validatePadding(input) {
const paddingMatch = input.match(/=+$/);
if (!paddingMatch) return true;
const paddingLength = paddingMatch[0].length;
if (!VALID_PADDING_LENGTHS.has(paddingLength)) {
throw new Base32Error(
`Invalid padding length: ${paddingLength}`,
'INVALID_PADDING'
);
}
if (input.indexOf('=') !== input.length - paddingLength) {
throw new Base32Error(
'Padding character in invalid position',
'INVALID_PADDING_POSITION'
);
}
return true;
}
/**
* Encodes a string or Uint8Array to Base32
* @param {string|Uint8Array} input - The input to encode
* @param {Object} [options] - Encoding options
* @param {boolean} [options.noPadding=false] - Whether to omit padding
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @param {string} [options.alphabet] - Optional custom Base32 alphabet
* @returns {string} The Base32 encoded string
* @throws {Base32Error} If the input is invalid
*/
function encode(input, options = {}) {
try {
if (options.alphabet) {
setCustomAlphabet(options.alphabet);
}
const data = typeof input === 'string'
? stringToUint8Array(input)
: input;
if (!(data instanceof Uint8Array)) {
throw new Base32Error(
'Input must be a string or Uint8Array',
'INVALID_INPUT_TYPE'
);
}
let bits = 0;
let value = 0;
let output = '';
for (let i = 0; i < data.length; i++) {
value = (value << 8) | data[i];
bits += 8;
while (bits >= 5) {
output += ENCODE_TABLE[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) {
output += ENCODE_TABLE[(value << (5 - bits)) & 31];
}
if (!options.noPadding) {
const pad = 8 - (output.length % 8);
if (pad !== 8) {
output += PADDING.repeat(pad);
}
}
return output;
} catch (error) {
if (error instanceof Base32Error) throw error;
throw new Base32Error('Encoding failed: ' + error.message, 'ENCODING_ERROR');
} finally {
if (options.alphabet) {
resetToDefaultAlphabet();
}
}
}
/**
* Decodes a Base32 string to Uint8Array
* @param {string} input - The Base32 string to decode
* @param {Object} [options] - Decoding options
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @param {boolean} [options.returnString=false] - Whether to return a string instead of Uint8Array
* @param {string} [options.alphabet] - Optional custom Base32 alphabet
* @returns {Uint8Array|string} The decoded data
* @throws {Base32Error} If the input is invalid
*/
function decode(input, options = {}) {
try {
if (options.alphabet) {
setCustomAlphabet(options.alphabet);
}
if (typeof input !== 'string') {
throw new Base32Error('Input must be a string', 'INVALID_INPUT_TYPE');
}
const upperInput = input.toUpperCase();
if (options.strict) {
validatePadding(upperInput);
}
const cleanInput = upperInput.replace(/=+$/, '');
if (!/^[A-Z2-7]*$/.test(cleanInput)) {
throw new Base32Error('Invalid Base32 character', 'INVALID_CHARACTER');
}
let bits = 0;
let value = 0;
const output = [];
for (let i = 0; i < cleanInput.length; i++) {
const charValue = DECODE_TABLE.get(cleanInput[i]);
value = (value << 5) | charValue;
bits += 5;
if (bits >= 8) {
output.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
const result = new Uint8Array(output);
return options.returnString ? uint8ArrayToString(result) : result;
} catch (error) {
if (error instanceof Base32Error) throw error;
throw new Base32Error('Decoding failed: ' + error.message, 'DECODING_ERROR');
} finally {
if (options.alphabet) {
resetToDefaultAlphabet();
}
}
}
/**
* Validates if a string is a valid Base32 encoding
* @param {string} input - The string to validate
* @param {Object} [options] - Validation options
* @param {boolean} [options.strict=false] - Whether to enable strict mode
* @returns {boolean} True if the string is valid Base32
*/
function isValid(input, options = {}) {
try {
if (typeof input !== 'string') return false;
const upperInput = input.toUpperCase();
if (options.strict) {
validatePadding(upperInput);
}
const cleanInput = upperInput.replace(/=+$/, '');
return /^[A-Z2-7]*$/.test(cleanInput);
} catch {
return false;
}
}
/**
* Encodes a string as Base32 without padding
* @param {string|Uint8Array} input - The input to encode
* @returns {string} The Base32 encoded string without padding
*/
function encodeNoPadding(input) {
return encode(input, { noPadding: true });
}
/**
* Normalizes a Base32 string
* @param {string} input - The Base32 string to normalize
* @returns {string} The normalized Base32 string
*/
function normalize(input) {
if (!isValid(input)) {
throw new Base32Error('Invalid Base32 string', 'INVALID_INPUT');
}
const base = input.toUpperCase().replace(/=+$/, '');
const padding = 8 - (base.length % 8);
return padding === 8 ? base : base + PADDING.repeat(padding);
}
/**
* Calculates the length of encoded Base32 string
* @param {number} inputLength - Length of input in bytes
* @param {Object} [options] - Options
* @param {boolean} [options.noPadding=false] - Whether padding will be omitted
* @returns {number} Length of Base32 encoded string
*/
function encodedLength(inputLength, options = {}) {
const baseLength = Math.ceil(inputLength * 8 / 5);
if (options.noPadding) return baseLength;
return Math.ceil(baseLength / 8) * 8;
}
module.exports = {
encode,
decode,
encodeNoPadding,
isValid,
normalize,
encodedLength,
Base32Error,
setCustomAlphabet,
resetToDefaultAlphabet
};