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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-17 20:43:24 +02:00
parent 196e10eacc
commit 87c5cedf3c
2 changed files with 122 additions and 0 deletions

63
src/core/totp.ts Normal file
View File

@@ -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');
}

59
test/totp.test.ts Normal file
View File

@@ -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/);
});
});