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:
63
src/core/totp.ts
Normal file
63
src/core/totp.ts
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user