Initial Commit
This commit is contained in:
58
packages/security-stack/security/otp/src/base32/base32.md
Normal file
58
packages/security-stack/security/otp/src/base32/base32.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `base32/base32`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 4648 §6 base32 encode + decode. The OTP profile defaults to **unpadded**
|
||||
output to match the Google Authenticator Key URI Format (`secret=...` is
|
||||
unpadded in the wild); spec-strict consumers can opt in via `padding: true`.
|
||||
Decode is case-insensitive and tolerates whitespace between groups so QR-code
|
||||
URLs that wrap long secrets round-trip cleanly.
|
||||
|
||||
## Features
|
||||
|
||||
- `base32Encode(bytes, opts?)` — bit-packed five-at-a-time encode.
|
||||
- `base32Decode(input)` — single-pass reverse-table decode.
|
||||
- 32-character alphabet `A–Z2–7` per RFC 4648 §6.
|
||||
- Lower-case alias only for letters (digits 2–7 are case-invariant).
|
||||
- `OTP.BASE32_INVALID` raised on non-alphabet character.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { base32Decode, base32Encode } from "@suaveplan/otp/base32";
|
||||
|
||||
const bytes = base32Decode("JBSWY3DPEHPK3PXP");
|
||||
const back = base32Encode(bytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { base32Encode } from "@suaveplan/otp/base32";
|
||||
|
||||
// Spec-strict consumer that needs `=` padding.
|
||||
const padded = base32Encode(new Uint8Array([0x66, 0x6f, 0x6f]), {
|
||||
padding: true,
|
||||
});
|
||||
// padded === "MZXW6==="
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `base32Encode(bytes: Uint8Array, opts?: { padding?: boolean }): string`
|
||||
— returns the base32 form. `padding` defaults to `false`.
|
||||
- `base32Decode(input: string): Uint8Array` — decodes padded or unpadded
|
||||
input; whitespace is stripped; case-insensitive; non-alphabet characters
|
||||
raise `OtpError(OTP.BASE32_INVALID)` with the offending position.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decode table is a 128-entry `Int8Array` indexed by char-code. Letters are
|
||||
aliased to their lower-case slots; digits are not aliased because '2'+32 ='R'
|
||||
which would otherwise overwrite the letter slot for `R`. The encoder emits
|
||||
unpadded output by default to match the prevailing Google Authenticator wire
|
||||
format; `padding: true` makes the output multiple-of-eight characters and
|
||||
RFC-4648-compliant for spec-strict consumers.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { base32Decode, base32Encode } from "./base32.js";
|
||||
|
||||
const ENCODER = new TextEncoder();
|
||||
|
||||
// RFC 4648 §10 base32 reference vectors.
|
||||
const RFC4648_VECTORS: ReadonlyArray<[string, string]> = [
|
||||
["", ""],
|
||||
["f", "MY======"],
|
||||
["fo", "MZXQ===="],
|
||||
["foo", "MZXW6==="],
|
||||
["foob", "MZXW6YQ="],
|
||||
["fooba", "MZXW6YTB"],
|
||||
["foobar", "MZXW6YTBOI======"],
|
||||
];
|
||||
|
||||
describe("base32Encode / base32Decode — RFC 4648 §10 vectors", () => {
|
||||
for (const [text, encodedPadded] of RFC4648_VECTORS) {
|
||||
it(`${JSON.stringify(text)} ↔ ${encodedPadded}`, {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = ENCODER.encode(text);
|
||||
expect(base32Encode(bytes, { padding: true })).toBe(encodedPadded);
|
||||
const stripped = encodedPadded.replace(/=+$/u, "");
|
||||
expect(base32Encode(bytes)).toBe(stripped);
|
||||
expect(Array.from(base32Decode(encodedPadded))).toEqual(
|
||||
Array.from(bytes)
|
||||
);
|
||||
expect(Array.from(base32Decode(stripped))).toEqual(
|
||||
Array.from(bytes)
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("base32Decode — case + whitespace tolerance", () => {
|
||||
it("accepts lowercase", { timeout: 5000 }, () => {
|
||||
expect(base32Decode("mzxw6ytb")).toEqual(ENCODER.encode("fooba"));
|
||||
});
|
||||
|
||||
it("accepts whitespace between groups", { timeout: 5000 }, () => {
|
||||
expect(base32Decode("MZXW 6YTB OI======")).toEqual(
|
||||
ENCODER.encode("foobar")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("base32 — round-trip property", () => {
|
||||
it("encode→decode round-trips for arbitrary bytes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let len = 0; len < 24; len += 1) {
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i += 1) bytes[i] = (i * 7 + 3) & 0xff;
|
||||
const enc = base32Encode(bytes);
|
||||
expect(Array.from(base32Decode(enc))).toEqual(Array.from(bytes));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("base32Decode — error paths", () => {
|
||||
it("rejects characters outside the alphabet", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => base32Decode("MZXW!YTB")).toThrow(/BASE32_INVALID/);
|
||||
});
|
||||
|
||||
it("rejects high-codepoint character", { timeout: 5000 }, () => {
|
||||
expect(() => base32Decode("ZZZÿ")).toThrow(/BASE32_INVALID/);
|
||||
});
|
||||
});
|
||||
93
packages/security-stack/security/otp/src/base32/base32.ts
Normal file
93
packages/security-stack/security/otp/src/base32/base32.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { OTP_CODES, otpError } from "../errors/codes.js";
|
||||
|
||||
/** RFC 4648 §6 base32 alphabet. */
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
/** Reverse lookup table: char-code → 5-bit value, or `-1` for non-alphabet. */
|
||||
const DECODE_TABLE = (() => {
|
||||
const t = new Int8Array(128).fill(-1);
|
||||
for (let i = 0; i < ALPHABET.length; i += 1) {
|
||||
const code = ALPHABET.charCodeAt(i);
|
||||
t[code] = i;
|
||||
// Lower-case alias so `base32Decode` is case-insensitive — only for
|
||||
// letters (A–Z, indices 0..25); the digits 2–7 (indices 26..31) have
|
||||
// no upper/lower distinction and aliasing them would collide with
|
||||
// uppercase letter slots (e.g. '2'+32 = 'R').
|
||||
if (i < 26) t[code + 32] = i;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
/** Options for `base32Encode`. */
|
||||
export interface Base32EncodeOptions {
|
||||
/**
|
||||
* When `true` the output is padded with `=` to a multiple of eight characters
|
||||
* per RFC 4648 §6. Defaults to `false` to match the Google Authenticator
|
||||
* Key URI Format which strips padding from `secret=...`.
|
||||
*/
|
||||
padding?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode bytes to RFC 4648 §6 base32. Padding defaults to `false` to match the
|
||||
* Google Authenticator Key URI Format (`secret=...` is unpadded in the wild);
|
||||
* pass `padding: true` for spec-strict consumers.
|
||||
*/
|
||||
export function base32Encode(
|
||||
bytes: Uint8Array,
|
||||
opts: Base32EncodeOptions = {}
|
||||
): string {
|
||||
const padding = opts.padding ?? false;
|
||||
let out = "";
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
for (const byte of bytes) {
|
||||
buffer = (buffer << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
const idx = (buffer >> bits) & 0x1f;
|
||||
out += ALPHABET[idx];
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
const idx = (buffer << (5 - bits)) & 0x1f;
|
||||
out += ALPHABET[idx];
|
||||
}
|
||||
if (padding) {
|
||||
while (out.length % 8 !== 0) out += "=";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an RFC 4648 §6 base32 string. Padded and unpadded inputs are both
|
||||
* accepted; lookups are case-insensitive. Whitespace is permitted between
|
||||
* groups (Google Authenticator's URL-form QR codes wrap long secrets).
|
||||
*/
|
||||
export function base32Decode(input: string): Uint8Array {
|
||||
const cleaned = input.replace(/\s+/g, "").replace(/=+$/u, "");
|
||||
if (cleaned.length === 0) return new Uint8Array(0);
|
||||
const out = new Uint8Array(Math.floor((cleaned.length * 5) / 8));
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
let outIdx = 0;
|
||||
for (let i = 0; i < cleaned.length; i += 1) {
|
||||
const code = cleaned.charCodeAt(i);
|
||||
const value = code < 128 ? DECODE_TABLE[code] : -1;
|
||||
if (value === undefined || value < 0) {
|
||||
throw otpError(
|
||||
OTP_CODES.BASE32_INVALID,
|
||||
`character '${cleaned[i]}' at position ${i} is not in the RFC 4648 §6 alphabet`
|
||||
);
|
||||
}
|
||||
buffer = (buffer << 5) | value;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out[outIdx] = (buffer >> bits) & 0xff;
|
||||
outIdx += 1;
|
||||
}
|
||||
}
|
||||
return out.subarray(0, outIdx);
|
||||
}
|
||||
5
packages/security-stack/security/otp/src/base32/index.ts
Normal file
5
packages/security-stack/security/otp/src/base32/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { Base32EncodeOptions } from "./base32.js";
|
||||
export {
|
||||
base32Decode,
|
||||
base32Encode,
|
||||
} from "./base32.js";
|
||||
Reference in New Issue
Block a user