From 87c5cedf3c5e0384396b5cfd4751cf7f671bf2e4 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 17 Sep 2026 20:43:24 +0200 Subject: [PATCH] Add RFC 6238 one-time codes WebUntis authenticates the mobile app not with a password but with a static base32 key, from which every request derives a fresh code. That is the credential an always-on server wants: it works under the school's SSO, needs no session, and expires only when a new key is generated. Implemented rather than pulled in. It is HMAC-SHA1 plus a truncation, node:crypto has the hard part, and a dependency that handles a credential is one worth not having. The tests are the RFC's own vectors, which validate the base32 table as much as the arithmetic. The code comes back zero-padded, as a string. One in ten begins with a zero and a JSON number would drop it, which is a login that fails a tenth of the time and looks like a server fault. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/totp.ts | 63 +++++++++++++++++++++++++++++++++++++++++++++++ test/totp.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/core/totp.ts create mode 100644 test/totp.test.ts diff --git a/src/core/totp.ts b/src/core/totp.ts new file mode 100644 index 0000000..976b164 --- /dev/null +++ b/src/core/totp.ts @@ -0,0 +1,63 @@ +/** + * RFC 6238 one-time codes, for the WebUntis mobile API. + * + * WebUntis authenticates the mobile app not with a password but with a static + * base32 key (the one behind the QR code in Profil → Freigaben) from which each + * request derives a fresh code. That makes the credential usable by an + * always-on server without a session to hold open — see core/untis.ts. + * + * Implemented here rather than pulled in: it is HMAC-SHA1 plus a truncation, + * `node:crypto` has the hard part, and a dependency that handles a credential + * is a dependency worth not having. + */ + +import { createHmac } from 'node:crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +/** + * Decodes base32 (RFC 4648) as written on a QR code: padding and lowercase are + * accepted, anything outside the alphabet is an error rather than a silently + * wrong key. + */ +export function base32Decode(value: string): Buffer { + const clean = value.replace(/[=\s]/g, '').toUpperCase(); + if (clean.length === 0) throw new Error('base32 value is empty'); + const bytes: number[] = []; + let buffer = 0; + let bits = 0; + for (const char of clean) { + const index = BASE32_ALPHABET.indexOf(char); + if (index === -1) throw new Error('base32 value contains a character outside A-Z and 2-7'); + buffer = (buffer << 5) | index; + bits += 5; + if (bits >= 8) { + bytes.push((buffer >>> (bits - 8)) & 0xff); + bits -= 8; + } + } + return Buffer.from(bytes); +} + +/** + * The current 6-digit code for a base32 secret, as a **zero-padded string**. + * + * A string on purpose: one code in ten starts with a zero, and sending it as a + * JSON number drops that digit. WebUntis accepts either shape, so the string is + * the one that is always right. + */ +export function totp(secret: string, at: number = Date.now(), stepSeconds = 30, digits = 6): string { + const counter = Math.floor(at / 1000 / stepSeconds); + const message = Buffer.alloc(8); + // Counter is 64-bit big-endian; Node has no writeUInt64BE. + message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0); + message.writeUInt32BE(counter >>> 0, 4); + + const digest = createHmac('sha1', base32Decode(secret)).update(message).digest(); + // RFC 6238 dynamic truncation: the low nibble of the last byte picks the + // 4-byte window, whose top bit is masked off to keep it positive. + const offset = digest[digest.length - 1]! & 0x0f; + const binary = + ((digest[offset]! & 0x7f) << 24) | (digest[offset + 1]! << 16) | (digest[offset + 2]! << 8) | digest[offset + 3]!; + return String(binary % 10 ** digits).padStart(digits, '0'); +} diff --git a/test/totp.test.ts b/test/totp.test.ts new file mode 100644 index 0000000..8c28b98 --- /dev/null +++ b/test/totp.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { base32Decode, totp } from '../src/core/totp.ts'; + +/** + * RFC 6238's own SHA-1 vectors, whose key is the ASCII string + * "12345678901234567890" — base32 below. They validate the decode as much as + * the code: a wrong base32 table would not reproduce a single one of them. + */ +const RFC_KEY = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; + +describe('totp', () => { + it('reproduces the RFC 6238 vectors', () => { + // Each vector's 8-digit code, truncated to the 6 digits WebUntis wants. + const vectors: [seconds: number, code: string][] = [ + [59, '287082'], + [1_111_111_109, '081804'], + [1_111_111_111, '050471'], + [1_234_567_890, '005924'], + [2_000_000_000, '279037'], + [20_000_000_000, '353130'], + ]; + for (const [seconds, code] of vectors) { + assert.equal(totp(RFC_KEY, seconds * 1000), code, `at ${seconds}s`); + } + }); + + it('keeps a leading zero, which is why the code travels as a string', () => { + const code = totp(RFC_KEY, 1_234_567_890_000); + assert.equal(code, '005924'); + assert.equal(code.length, 6); + // The bug this guards: sending the code as a JSON number would make it 5924. + assert.notEqual(String(Number(code)), code); + }); + + it('changes with the 30-second step and not within it', () => { + const base = 1_700_000_000_000; + assert.equal(totp(RFC_KEY, base), totp(RFC_KEY, base + 29_000 - (base % 30_000))); + assert.notEqual(totp(RFC_KEY, base), totp(RFC_KEY, base + 30_000)); + }); +}); + +describe('base32Decode', () => { + it('decodes to the bytes behind the RFC key', () => { + assert.equal(base32Decode(RFC_KEY).toString('utf8'), '12345678901234567890'); + }); + + it('accepts lowercase, padding and spaces, as a copied key arrives', () => { + const expected = base32Decode('JBSWY3DP'); + assert.deepEqual(base32Decode('jbswy3dp'), expected); + assert.deepEqual(base32Decode('JBSW Y3DP'), expected); + assert.deepEqual(base32Decode('JBSWY3DP===='), expected); + }); + + it('refuses a value that is not base32 rather than deriving a wrong code', () => { + assert.throws(() => base32Decode('nope!'), /outside A-Z and 2-7/); + assert.throws(() => base32Decode(' '), /empty/); + }); +});