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>
60 lines
2.2 KiB
TypeScript
60 lines
2.2 KiB
TypeScript
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/);
|
|
});
|
|
});
|