From dc8afcb6344e8ad0ad8ff822503b1904ffd4c7b9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 20 Apr 2026 19:44:18 -0400 Subject: [PATCH] feat(ext): base32 encode/decode for TOTP secret parse --- extension/src/shared/__tests__/base32.test.ts | 33 ++++++++++++++ extension/src/shared/base32.ts | 44 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 extension/src/shared/__tests__/base32.test.ts create mode 100644 extension/src/shared/base32.ts diff --git a/extension/src/shared/__tests__/base32.test.ts b/extension/src/shared/__tests__/base32.test.ts new file mode 100644 index 0000000..5b6a381 --- /dev/null +++ b/extension/src/shared/__tests__/base32.test.ts @@ -0,0 +1,33 @@ +// extension/src/shared/__tests__/base32.test.ts +import { describe, expect, it } from 'vitest'; +import { base32Decode, base32Encode } from '../base32'; + +describe('base32', () => { + // RFC 4648 ยง 10 test vectors + it('encodes empty', () => expect(base32Encode(new Uint8Array())).toBe('')); + it('encodes "f"', () => expect(base32Encode(new TextEncoder().encode('f'))).toBe('MY')); + it('encodes "fo"', () => expect(base32Encode(new TextEncoder().encode('fo'))).toBe('MZXQ')); + it('encodes "foo"', () => expect(base32Encode(new TextEncoder().encode('foo'))).toBe('MZXW6')); + it('encodes "foob"', () => expect(base32Encode(new TextEncoder().encode('foob'))).toBe('MZXW6YQ')); + it('encodes "fooba"', () => expect(base32Encode(new TextEncoder().encode('fooba'))).toBe('MZXW6YTB')); + it('encodes "foobar"',() => expect(base32Encode(new TextEncoder().encode('foobar'))).toBe('MZXW6YTBOI')); + + it('decodes round-trip', () => { + const bytes = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a]); + expect(base32Decode(base32Encode(bytes))).toEqual(bytes); + }); + + it('decodes case-insensitively', () => { + expect(base32Decode('mzxw6')).toEqual(new TextEncoder().encode('foo')); + }); + + it('decodes ignoring whitespace and padding', () => { + expect(base32Decode('JBSW Y3DP EHPK 3PXP==')).toEqual( + base32Decode('JBSWY3DPEHPK3PXP'), + ); + }); + + it('throws on invalid characters', () => { + expect(() => base32Decode('MZ!W6')).toThrow(); + }); +}); diff --git a/extension/src/shared/base32.ts b/extension/src/shared/base32.ts new file mode 100644 index 0000000..ba0d01c --- /dev/null +++ b/extension/src/shared/base32.ts @@ -0,0 +1,44 @@ +/// Minimal RFC 4648 base32 encode/decode for TOTP secret parsing. +/// +/// Mirrors the encoder in crates/relicario-core/src/item.rs:base32_encode. +/// Decode is case-insensitive, tolerates whitespace and `=` padding. + +const ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export function base32Encode(bytes: Uint8Array): string { + let out = ''; + let buffer = 0; + let bits = 0; + for (const b of bytes) { + buffer = (buffer << 8) | b; + bits += 8; + while (bits >= 5) { + const idx = (buffer >> (bits - 5)) & 0x1f; + out += ALPHA[idx]; + bits -= 5; + } + } + if (bits > 0) { + const idx = (buffer << (5 - bits)) & 0x1f; + out += ALPHA[idx]; + } + return out; +} + +export function base32Decode(input: string): Uint8Array { + const cleaned = input.replace(/\s+/g, '').replace(/=+$/g, '').toUpperCase(); + const out: number[] = []; + let buffer = 0; + let bits = 0; + for (const ch of cleaned) { + const idx = ALPHA.indexOf(ch); + if (idx === -1) throw new Error(`base32: invalid character "${ch}"`); + buffer = (buffer << 5) | idx; + bits += 5; + if (bits >= 8) { + out.push((buffer >> (bits - 8)) & 0xff); + bits -= 8; + } + } + return new Uint8Array(out); +}