feat(ext): base32 encode/decode for TOTP secret parse
This commit is contained in:
33
extension/src/shared/__tests__/base32.test.ts
Normal file
33
extension/src/shared/__tests__/base32.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
44
extension/src/shared/base32.ts
Normal file
44
extension/src/shared/base32.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user