/** * Device credentials. * * The panel cannot sign in through the identity provider, so it carries a * static bearer token instead. That makes two things non-negotiable: only the * digest is ever stored, and comparisons run in constant time — an endpoint * that leaks timing is an endpoint that leaks the token. */ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; /** Unambiguous in print: no O/0, no I/1. Friendly ids get read aloud. */ const FRIENDLY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; /** 256 bits of entropy, URL-safe so it survives a captive-portal form. */ export function generateDeviceToken(): string { return randomBytes(32).toString('base64url'); } export function hashToken(token: string): string { return createHash('sha256').update(token, 'utf8').digest('hex'); } /** * Constant-time comparison of a presented token against a stored digest. * * Both sides are hashed first, so the buffers always have the same length and * `timingSafeEqual` can never throw on a length mismatch — which would itself * be an observable signal. */ export function tokenMatches(presented: string, storedHash: string): boolean { const presentedDigest = Buffer.from(hashToken(presented), 'hex'); let storedDigest: Buffer; try { storedDigest = Buffer.from(storedHash, 'hex'); } catch { return false; } if (storedDigest.length !== presentedDigest.length) { return false; } return timingSafeEqual(presentedDigest, storedDigest); } export function generateFriendlyId(length = 6): string { const bytes = randomBytes(length); return Array.from(bytes, (byte) => FRIENDLY_ALPHABET[byte % FRIENDLY_ALPHABET.length]).join(''); } /** * Normalises a MAC address to upper-case colon-separated form. * * Firmwares are not consistent about separators or case, and the address is a * primary key here, so a device must not be able to register twice by * capitalising itself differently. */ export function normaliseMac(value: string | null | undefined): string | null { if (!value) { return null; } const hex = value.replace(/[^0-9a-fA-F]/g, '').toUpperCase(); if (hex.length !== 12) { return null; } return (hex.match(/.{2}/g) ?? []).join(':'); }