import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' const CODE_PATTERN = /^\d{6}$/ function digest(value) { return createHash('sha256').update(value).digest('hex') } function token(prefix, random) { return `${prefix}_${random(32).toString('base64url')}` } function base32Encode(value) { let bits = 0 let buffer = 0 let output = '' for (const byte of value) { buffer = (buffer << 8) | byte bits += 8 while (bits >= 5) { output += BASE32_ALPHABET[(buffer >>> (bits - 5)) & 31] bits -= 5 } } if (bits > 0) output += BASE32_ALPHABET[(buffer << (5 - bits)) & 31] return output } function base32Decode(value) { if (typeof value !== 'string' || value.length === 0 || /[^A-Z2-7]/u.test(value)) return undefined let bits = 0 let buffer = 0 const output = [] for (const character of value) { buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character) bits += 5 if (bits >= 8) { output.push((buffer >>> (bits - 8)) & 255) bits -= 8 } } return Buffer.from(output) } function counterBuffer(counter) { const value = Buffer.alloc(8) value.writeBigUInt64BE(BigInt(counter)) return value } export function generateTotpSecret(random = randomBytes) { return base32Encode(random(20)) } export function validTotpSecret(secret) { return base32Decode(secret)?.byteLength === 20 } export function totpCode(secret, { now = Date.now(), offset = 0 } = {}) { const key = base32Decode(secret) if (key?.byteLength !== 20) throw new TypeError('TOTP secret is invalid') const counter = Math.floor(now / 30_000) + offset const hmac = createHmac('sha1', key).update(counterBuffer(counter)).digest() const position = hmac.at(-1) & 15 const value = (hmac.readUInt32BE(position) & 0x7fffffff) % 1_000_000 return String(value).padStart(6, '0') } export function verifyTotpCode(code, secret, { now = Date.now(), window = 1 } = {}) { if (typeof code !== 'string' || !CODE_PATTERN.test(code)) return false const actual = Buffer.from(code) for (let offset = -window; offset <= window; offset += 1) { const expected = Buffer.from(totpCode(secret, { now, offset })) if (timingSafeEqual(actual, expected)) return true } return false } export function totpUri({ secret, username, issuer = 'DSH Docker' }) { if (!validTotpSecret(secret)) throw new TypeError('TOTP secret is invalid') const label = `${issuer}:${username}` const query = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' }) return `otpauth://totp/${encodeURIComponent(label)}?${query}` } export class TotpFlowStore { constructor({ now = Date.now, random = randomBytes, enrollmentTtlMs = 5 * 60_000, loginTtlMs = 5 * 60_000 } = {}) { this.now = now this.random = random this.enrollmentTtlMs = enrollmentTtlMs this.loginTtlMs = loginTtlMs this.enrollments = new Map() this.disableConfirmations = new Map() this.logins = new Map() } prune() { const current = this.now() for (const [key, value] of this.enrollments) if (value.expiresAt <= current) this.enrollments.delete(key) for (const [key, value] of this.disableConfirmations) if (value.expiresAt <= current) this.disableConfirmations.delete(key) for (const [key, value] of this.logins) if (value.expiresAt <= current) this.logins.delete(key) } createEnrollment(account, sessionId) { this.prune() for (const [key, value] of this.enrollments) { if (value.accountId === account.accountId && value.sessionId === sessionId) this.enrollments.delete(key) } const value = token('dshte', this.random) const secret = generateTotpSecret(this.random) const expiresAt = this.now() + this.enrollmentTtlMs this.enrollments.set(digest(value), { accountId: account.accountId, accountRevision: account.revision, sessionId, secret, confirmed: false, expiresAt, }) return Object.freeze({ token: value, secret, expiresAt: new Date(expiresAt).toISOString() }) } enrollment(value, account, sessionId) { this.prune() if (typeof value !== 'string' || value.length > 512) return undefined const key = digest(value) const enrollment = this.enrollments.get(key) if (enrollment === undefined || enrollment.accountId !== account.accountId || enrollment.accountRevision !== account.revision || enrollment.sessionId !== sessionId) return undefined return { key, value: enrollment } } cancelEnrollment(value, account, sessionId) { const enrollment = this.enrollment(value, account, sessionId) return enrollment === undefined ? false : this.enrollments.delete(enrollment.key) } confirmEnrollment(key) { const enrollment = this.enrollments.get(key) if (enrollment === undefined) return false enrollment.confirmed = true return true } consumeEnrollment(key) { return this.enrollments.delete(key) } createDisableConfirmation(account, sessionId) { this.prune() for (const [key, value] of this.disableConfirmations) { if (value.accountId === account.accountId && value.sessionId === sessionId) this.disableConfirmations.delete(key) } const value = token('dshtd', this.random) const expiresAt = this.now() + this.enrollmentTtlMs this.disableConfirmations.set(digest(value), { accountId: account.accountId, accountRevision: account.revision, sessionId, confirmed: false, expiresAt, }) return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() }) } disableConfirmation(value, account, sessionId) { this.prune() if (typeof value !== 'string' || value.length > 512) return undefined const key = digest(value) const confirmation = this.disableConfirmations.get(key) if (confirmation === undefined || confirmation.accountId !== account.accountId || confirmation.accountRevision !== account.revision || confirmation.sessionId !== sessionId) return undefined return { key, value: confirmation } } cancelDisableConfirmation(value, account, sessionId) { const confirmation = this.disableConfirmation(value, account, sessionId) return confirmation === undefined ? false : this.disableConfirmations.delete(confirmation.key) } confirmDisable(key) { const confirmation = this.disableConfirmations.get(key) if (confirmation === undefined) return false confirmation.confirmed = true return true } consumeDisableConfirmation(key) { return this.disableConfirmations.delete(key) } createLogin(account, details) { this.prune() const value = token('dshtl', this.random) const expiresAt = this.now() + this.loginTtlMs this.logins.set(digest(value), { accountId: account.accountId, accountRevision: account.revision, ...details, expiresAt, }) return Object.freeze({ token: value, expiresAt: new Date(expiresAt).toISOString() }) } login(value, account) { this.prune() if (typeof value !== 'string' || value.length > 512) return undefined const key = digest(value) const login = this.logins.get(key) if (login === undefined || login.accountId !== account.accountId || login.accountRevision !== account.revision) return undefined return { key, value: login } } consumeLogin(key) { return this.logins.delete(key) } clear() { this.enrollments.clear() this.disableConfirmations.clear() this.logins.clear() } } export class TotpRetryLimiter { constructor({ now = Date.now, threshold = 3, retryMs = 10_000, dailyWindowMs = 24 * 60 * 60_000, sourceDailyLimit = 25, globalDailyLimit = 50, } = {}) { this.now = now this.threshold = threshold this.retryMs = retryMs this.dailyWindowMs = dailyWindowMs this.sourceDailyLimit = sourceDailyLimit this.globalDailyLimit = globalDailyLimit this.entries = new Map() this.globalFailures = new Map() } key(accountId, source) { return `${accountId}\u0000${source}` } prune(values) { const cutoff = this.now() - this.dailyWindowMs while (values.length > 0 && values[0] <= cutoff) values.shift() } retry(accountId, source) { const entry = this.entries.get(this.key(accountId, source)) const global = this.globalFailures.get(accountId) ?? [] if (entry !== undefined) this.prune(entry.failuresInWindow) this.prune(global) const until = values => Math.max(1, Math.ceil((values[0] + this.dailyWindowMs - this.now()) / 1000)) if (global.length >= this.globalDailyLimit) return { kind: 'rate', retryAfterSeconds: until(global) } if (entry?.failuresInWindow.length >= this.sourceDailyLimit) { return { kind: 'rate', retryAfterSeconds: until(entry.failuresInWindow) } } if (entry !== undefined && entry.blockedUntil > this.now()) { return { kind: 'retry', retryAfterSeconds: Math.max(1, Math.ceil((entry.blockedUntil - this.now()) / 1000)) } } return { kind: null, retryAfterSeconds: 0 } } fail(accountId, source) { const key = this.key(accountId, source) const entry = this.entries.get(key) ?? { consecutiveFailures: 0, blockedUntil: 0, failuresInWindow: [] } const global = this.globalFailures.get(accountId) ?? [] this.prune(entry.failuresInWindow) this.prune(global) entry.consecutiveFailures += 1 entry.failuresInWindow.push(this.now()) global.push(this.now()) if (entry.consecutiveFailures >= this.threshold) entry.blockedUntil = this.now() + this.retryMs this.entries.set(key, entry) this.globalFailures.set(accountId, global) return this.retry(accountId, source) } succeed(accountId, source) { const entry = this.entries.get(this.key(accountId, source)) if (entry === undefined) return entry.consecutiveFailures = 0 entry.blockedUntil = 0 } clearDailyLimits(accountId, { globalOnly = false } = {}) { let cleared = this.globalFailures.delete(accountId) ? 1 : 0 if (globalOnly) return { cleared } for (const [key, entry] of this.entries) { if (!key.startsWith(`${accountId}\u0000`)) continue if (entry.failuresInWindow.length > 0) { entry.failuresInWindow = [] cleared += 1 } } return { cleared } } }