diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index c178fd7..e82144e 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 82.7% + + coverage: 82% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 82.7% - 82.7% + 82% + 82% diff --git a/src/controllers/totp.ts b/src/controllers/totp.ts new file mode 100644 index 0000000..dc2295d --- /dev/null +++ b/src/controllers/totp.ts @@ -0,0 +1,245 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Request, Response } from 'express'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { issueSessionAndRespond } from '../services/sessionIssuance.js'; +import { recordStepUpVerification, serializeStepUpStatus } from '../services/stepUpService.js'; +import { + disableTotp, + getTotpStatus, + startTotpEnrollment, + verifyEnabledTotp, + verifyTotpEnrollment, +} from '../services/totpService.js'; +import { AuthenticatedRequest } from '../types/types.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('totp'); +const AUTH_MODE: 'web' | 'server' = process.env.AUTH_MODE! as 'web' | 'server'; + +function serializeDate(value: Date | null) { + return value?.toISOString() ?? null; +} + +function getAuthenticatedUser(req: Request) { + return (req as AuthenticatedRequest).user; +} + +export const getCurrentTotpStatus = async (req: Request, res: Response) => { + const user = getAuthenticatedUser(req); + + if (!user?.id) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const status = await getTotpStatus(user.id); + + return res.json({ + enabled: status.enabled, + verifiedAt: serializeDate(status.verifiedAt), + lastUsedAt: serializeDate(status.lastUsedAt), + }); +}; + +export const startCurrentTotpEnrollment = async (req: Request, res: Response) => { + const user = getAuthenticatedUser(req); + + if (!user?.id || !user.email) { + await AuthEventService.log({ + userId: user?.id ?? null, + type: 'totp_suspicious', + req, + metadata: { reason: 'Missing authenticated user or email' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } + + try { + const { app_name } = await getSystemConfig(); + const enrollment = await startTotpEnrollment({ + userId: user.id, + email: user.email, + issuer: app_name || 'Seamless Auth', + }); + + await AuthEventService.log({ + userId: user.id, + type: 'totp_enrollment_started', + req, + }); + + return res.status(200).json({ + message: 'Success', + ...enrollment, + }); + } catch (error) { + logger.error(`Failed to start TOTP enrollment: ${error}`); + await AuthEventService.log({ + userId: user.id, + type: 'totp_failed', + req, + metadata: { reason: 'Enrollment start failed' }, + }); + return res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const verifyCurrentTotpEnrollment = async (req: Request, res: Response) => { + const user = getAuthenticatedUser(req); + const { code } = req.body; + + if (!user?.id) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const result = await verifyTotpEnrollment(user.id, code); + + if (!result.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'totp_failed', + req, + metadata: { reason: result.reason }, + }); + return res.status(401).json({ error: 'totp_verification_failed' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'totp_enrollment_success', + req, + }); + + return res.json({ message: 'Success' }); +}; + +export const disableCurrentTotp = async (req: Request, res: Response) => { + const user = getAuthenticatedUser(req); + const { code } = req.body; + + if (!user?.id) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const result = await disableTotp(user.id, code); + + if (!result.disabled) { + await AuthEventService.log({ + userId: user.id, + type: 'totp_failed', + req, + metadata: { reason: result.reason }, + }); + return res.status(401).json({ error: 'totp_disable_failed' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'totp_disabled', + req, + }); + + return res.json({ message: 'Success' }); +}; + +export const verifyTotpLogin = async (req: Request, res: Response) => { + const user = getAuthenticatedUser(req); + const { code } = req.body; + + if (!user?.id) { + await AuthEventService.log({ + userId: null, + type: 'totp_suspicious', + req, + metadata: { reason: 'Missing pre-authenticated user' }, + }); + return res.status(401).json({ error: 'unauthorized' }); + } + + const result = await verifyEnabledTotp(user.id, code); + + if (!result.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'totp_failed', + req, + metadata: { reason: result.reason, flow: 'login' }, + }); + return res.status(401).json({ error: 'totp_verification_failed' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'totp_success', + req, + metadata: { flow: 'login' }, + }); + + await issueSessionAndRespond({ + user: { + id: user.id, + email: user.email, + phone: user.phone, + roles: user.roles ?? [], + }, + req, + res, + authMode: AUTH_MODE, + clearExistingCookies: true, + }); + + await user.update({ + lastLogin: new Date(), + }); +}; + +export const verifyTotpMfa = async (req: Request, res: Response) => { + const authReq = req as AuthenticatedRequest; + const user = authReq.user; + const { code } = req.body; + + if (!user?.id || !authReq.sessionId) { + return res.status(401).json({ error: 'unauthorized' }); + } + + const result = await verifyEnabledTotp(user.id, code); + + if (!result.verified) { + await AuthEventService.log({ + userId: user.id, + type: 'mfa_otp_failed', + req, + metadata: { reason: result.reason, method: 'totp' }, + }); + return res.status(401).json({ error: 'totp_verification_failed' }); + } + + const status = await recordStepUpVerification({ + sessionId: authReq.sessionId, + userId: user.id, + method: 'totp', + }); + + if (!status) { + return res.status(401).json({ error: 'unauthorized' }); + } + + await AuthEventService.log({ + userId: user.id, + type: 'mfa_otp_success', + req, + metadata: { method: 'totp' }, + }); + + return res.json({ + message: 'Success', + ...serializeStepUpStatus(status), + method: 'totp', + }); +}; diff --git a/src/migrations/20260516140000-add-totp-credentials.cjs b/src/migrations/20260516140000-add-totp-credentials.cjs new file mode 100644 index 0000000..bc6ca7f --- /dev/null +++ b/src/migrations/20260516140000-add-totp-credentials.cjs @@ -0,0 +1,62 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + */ +'use strict'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS public.totp_credentials ( + id uuid NOT NULL, + "userId" uuid NOT NULL, + "secretCiphertext" text NOT NULL, + "secretIv" character varying(255) NOT NULL, + "secretTag" character varying(255) NOT NULL, + issuer character varying(255) NOT NULL, + "accountName" character varying(255) NOT NULL, + enabled boolean DEFAULT false NOT NULL, + "verifiedAt" timestamp with time zone, + "lastUsedAt" timestamp with time zone, + "lastUsedCounter" bigint, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL + ); + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'totp_credentials_pkey' + ) THEN + ALTER TABLE ONLY public.totp_credentials + ADD CONSTRAINT totp_credentials_pkey PRIMARY KEY (id); + END IF; + END $$; + + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'totp_credentials_userId_fkey' + ) THEN + ALTER TABLE ONLY public.totp_credentials + ADD CONSTRAINT "totp_credentials_userId_fkey" + FOREIGN KEY ("userId") REFERENCES public.users(id) ON UPDATE CASCADE ON DELETE CASCADE; + END IF; + END $$; + + CREATE INDEX IF NOT EXISTS idx_totp_credentials_user_id + ON public.totp_credentials USING btree ("userId"); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_totp_credentials_one_enabled_per_user + ON public.totp_credentials USING btree ("userId") + WHERE enabled = true; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS public.totp_credentials; + `); + }, +}; diff --git a/src/models/totpCredentials.ts b/src/models/totpCredentials.ts new file mode 100644 index 0000000..8d7c285 --- /dev/null +++ b/src/models/totpCredentials.ts @@ -0,0 +1,123 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { Association, DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +import type { User } from './users.js'; + +export interface TotpCredentialAttributes { + id: string; + userId: string; + secretCiphertext: string; + secretIv: string; + secretTag: string; + issuer: string; + accountName: string; + enabled: boolean; + verifiedAt?: Date | null; + lastUsedAt?: Date | null; + lastUsedCounter?: number | null; + createdAt?: Date; + updatedAt?: Date; +} + +type TotpCredentialCreationAttributes = Optional< + TotpCredentialAttributes, + 'id' | 'enabled' | 'verifiedAt' | 'lastUsedAt' | 'lastUsedCounter' +>; + +export class TotpCredential + extends Model + implements TotpCredentialAttributes +{ + declare id: string; + declare userId: string; + declare secretCiphertext: string; + declare secretIv: string; + declare secretTag: string; + declare issuer: string; + declare accountName: string; + declare enabled: boolean; + declare verifiedAt: Date | null; + declare lastUsedAt: Date | null; + declare lastUsedCounter: number | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; + + public static associations: { + user: Association; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static associate(models: any) { + TotpCredential.belongsTo(models.User, { + foreignKey: 'userId', + as: 'user', + }); + } +} + +const initializeTotpCredentialModel = (sequelize: Sequelize) => { + TotpCredential.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + secretCiphertext: { + type: DataTypes.TEXT, + allowNull: false, + }, + secretIv: { + type: DataTypes.STRING, + allowNull: false, + }, + secretTag: { + type: DataTypes.STRING, + allowNull: false, + }, + issuer: { + type: DataTypes.STRING, + allowNull: false, + }, + accountName: { + type: DataTypes.STRING, + allowNull: false, + }, + enabled: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + verifiedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + lastUsedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + lastUsedCounter: { + type: DataTypes.BIGINT, + allowNull: true, + }, + }, + { + sequelize, + tableName: 'totp_credentials', + modelName: 'TotpCredential', + }, + ); + + return TotpCredential; +}; + +export default initializeTotpCredentialModel; diff --git a/src/models/users.ts b/src/models/users.ts index 9152725..c3f2d6d 100644 --- a/src/models/users.ts +++ b/src/models/users.ts @@ -7,6 +7,7 @@ import { Association, DataTypes, Model, Sequelize } from 'sequelize'; import type { Credential } from './credentials.js'; +import type { TotpCredential } from './totpCredentials.js'; export interface UserAttributes { id?: string; @@ -26,6 +27,7 @@ export interface UserAttributes { createdAt?: Date; updatedAt?: Date; credentials?: Credential[]; + totpCredentials?: TotpCredential[]; } export class User extends Model implements UserAttributes { @@ -46,9 +48,11 @@ export class User extends Model implements UserAttributes { declare readonly createdAt: Date; declare readonly updatedAt: Date; declare readonly credentials?: Credential[]; + declare readonly totpCredentials?: TotpCredential[]; public static associations: { credentials: Association; + totpCredentials: Association; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -58,6 +62,11 @@ export class User extends Model implements UserAttributes { onDelete: 'CASCADE', as: 'credentials', }); + User.hasMany(models.TotpCredential, { + foreignKey: 'userId', + onDelete: 'CASCADE', + as: 'totpCredentials', + }); } } diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts new file mode 100644 index 0000000..4ff3a01 --- /dev/null +++ b/src/routes/totp.routes.ts @@ -0,0 +1,139 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { + disableCurrentTotp, + getCurrentTotpStatus, + startCurrentTotpEnrollment, + verifyCurrentTotpEnrollment, + verifyTotpLogin, + verifyTotpMfa, +} from '../controllers/totp.js'; +import { createRouter } from '../lib/createRouter.js'; +import { ErrorSchema, InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { StepUpSuccessSchema } from '../schemas/stepUp.responses.js'; +import { TotpVerifyRequestSchema } from '../schemas/totp.requests.js'; +import { + TotpEnrollmentStartSchema, + TotpStatusSchema, + TotpVerifySuccessSchema, +} from '../schemas/totp.responses.js'; +import { WebAuthnTokenSuccessSchema } from '../schemas/webauthn.responses.js'; + +const totpRouter = createRouter('/totp'); + +totpRouter.get( + '/status', + { + auth: 'access', + summary: 'Get TOTP status for the current user', + tags: ['TOTP'], + + schemas: { + response: { + 200: TotpStatusSchema, + 401: ErrorSchema, + }, + }, + }, + getCurrentTotpStatus, +); + +totpRouter.post( + '/enroll/start', + { + auth: 'access', + summary: 'Start TOTP enrollment', + tags: ['TOTP'], + + schemas: { + response: { + 200: TotpEnrollmentStartSchema, + 401: ErrorSchema, + 500: InternalErrorSchema, + }, + }, + }, + startCurrentTotpEnrollment, +); + +totpRouter.post( + '/enroll/verify', + { + auth: 'access', + summary: 'Verify TOTP enrollment', + tags: ['TOTP'], + + schemas: { + body: TotpVerifyRequestSchema, + + response: { + 200: TotpVerifySuccessSchema, + 401: ErrorSchema, + }, + }, + }, + verifyCurrentTotpEnrollment, +); + +totpRouter.post( + '/disable', + { + auth: 'access', + summary: 'Disable TOTP for the current user', + tags: ['TOTP'], + + schemas: { + body: TotpVerifyRequestSchema, + + response: { + 200: MessageSchema, + 401: ErrorSchema, + }, + }, + }, + disableCurrentTotp, +); + +totpRouter.post( + '/verify-login', + { + auth: 'ephemeral', + summary: 'Verify TOTP during login', + tags: ['TOTP'], + + schemas: { + body: TotpVerifyRequestSchema, + + response: { + 200: WebAuthnTokenSuccessSchema, + 401: ErrorSchema, + }, + }, + }, + verifyTotpLogin, +); + +totpRouter.post( + '/verify-mfa', + { + auth: 'access', + summary: 'Verify TOTP for MFA or step-up authentication', + tags: ['TOTP'], + + schemas: { + body: TotpVerifyRequestSchema, + + response: { + 200: StepUpSuccessSchema, + 401: ErrorSchema, + }, + }, + }, + verifyTotpMfa, +); + +export default totpRouter.router; diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 291ff6d..d646f7f 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -61,6 +61,12 @@ export const AUTH_EVENT_TYPES = [ 'system_config_error', 'system_config_read', 'system_config_updated', + 'totp_disabled', + 'totp_enrollment_started', + 'totp_enrollment_success', + 'totp_failed', + 'totp_success', + 'totp_suspicious', 'user_created', 'user_data_failed', 'user_data_success', diff --git a/src/schemas/stepUp.responses.ts b/src/schemas/stepUp.responses.ts index 039480d..57d2831 100644 --- a/src/schemas/stepUp.responses.ts +++ b/src/schemas/stepUp.responses.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; export const StepUpStatusSchema = z.object({ fresh: z.boolean(), - method: z.literal('webauthn').nullable(), + method: z.enum(['webauthn', 'totp']).nullable(), verifiedAt: z.string().nullable(), expiresAt: z.string().nullable(), maxAgeSeconds: z.number(), @@ -17,7 +17,7 @@ export const StepUpStatusSchema = z.object({ export const StepUpSuccessSchema = z.object({ message: z.string(), fresh: z.boolean(), - method: z.literal('webauthn'), + method: z.enum(['webauthn', 'totp']), verifiedAt: z.string(), expiresAt: z.string(), maxAgeSeconds: z.number(), diff --git a/src/schemas/totp.requests.ts b/src/schemas/totp.requests.ts new file mode 100644 index 0000000..07fa13f --- /dev/null +++ b/src/schemas/totp.requests.ts @@ -0,0 +1,11 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +export const TotpVerifyRequestSchema = z.object({ + code: z.string().regex(/^\d{6}$/), +}); diff --git a/src/schemas/totp.responses.ts b/src/schemas/totp.responses.ts new file mode 100644 index 0000000..e408480 --- /dev/null +++ b/src/schemas/totp.responses.ts @@ -0,0 +1,28 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +export const TotpStatusSchema = z.object({ + enabled: z.boolean(), + verifiedAt: z.string().nullable(), + lastUsedAt: z.string().nullable(), +}); + +export const TotpEnrollmentStartSchema = z.object({ + message: z.string(), + secret: z.string(), + otpauthUrl: z.string(), + issuer: z.string(), + accountName: z.string(), + algorithm: z.literal('SHA1'), + digits: z.number(), + period: z.number(), +}); + +export const TotpVerifySuccessSchema = z.object({ + message: z.string(), +}); diff --git a/src/services/stepUpService.ts b/src/services/stepUpService.ts index 2602e7b..0a13f9e 100644 --- a/src/services/stepUpService.ts +++ b/src/services/stepUpService.ts @@ -8,7 +8,7 @@ import { Session } from '../models/sessions.js'; export const DEFAULT_STEP_UP_MAX_AGE_SECONDS = 5 * 60; -export type StepUpMethod = 'webauthn'; +export type StepUpMethod = 'webauthn' | 'totp'; export interface StepUpStatus { sessionFound: boolean; @@ -45,7 +45,10 @@ export function getStepUpStatusFromSession( } const verifiedAt = session.stepUpVerifiedAt ?? null; - const method = session.stepUpMethod === 'webauthn' ? session.stepUpMethod : null; + const method = + session.stepUpMethod === 'webauthn' || session.stepUpMethod === 'totp' + ? session.stepUpMethod + : null; const stepUpExpiresAt = verifiedAt ? expiresAt(verifiedAt, maxAgeSeconds) : null; return { diff --git a/src/services/totpService.ts b/src/services/totpService.ts new file mode 100644 index 0000000..ffceefc --- /dev/null +++ b/src/services/totpService.ts @@ -0,0 +1,229 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'; + +import { TotpCredential } from '../models/totpCredentials.js'; +import { + buildTotpUri, + DEFAULT_TOTP_ALGORITHM, + DEFAULT_TOTP_DIGITS, + DEFAULT_TOTP_PERIOD_SECONDS, + generateTotpSecret, + verifyTotpCode, +} from '../utils/totp.js'; + +const TOTP_CIPHER = 'aes-256-gcm'; + +type TotpCredentialLike = Pick< + TotpCredential, + 'secretCiphertext' | 'secretIv' | 'secretTag' | 'lastUsedCounter' | 'update' | 'destroy' +>; + +function randomBuffer(length: number) { + const value = randomBytes(length); + + if (Buffer.isBuffer(value)) { + return value; + } + + const fallback = Buffer.from(String(value)); + + if (fallback.length >= length) { + return fallback.subarray(0, length); + } + + return Buffer.concat([fallback, Buffer.alloc(length - fallback.length)]).subarray(0, length); +} + +function getTotpEncryptionKey() { + const explicitSecret = + process.env.TOTP_SECRET_ENCRYPTION_KEY?.trim() || process.env.API_SERVICE_TOKEN?.trim(); + + if (explicitSecret) { + return createHash('sha256').update(explicitSecret).digest(); + } + + if (process.env.NODE_ENV === 'production') { + throw new Error('TOTP_SECRET_ENCRYPTION_KEY or API_SERVICE_TOKEN must be set in production.'); + } + + return createHash('sha256') + .update(`dev-totp-secret:${process.env.APP_ID ?? 'local'}:${process.env.ISSUER ?? 'local'}`) + .digest(); +} + +export function encryptTotpSecret(secret: string) { + const iv = randomBuffer(12); + const cipher = createCipheriv(TOTP_CIPHER, getTotpEncryptionKey(), iv); + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]); + + return { + secretCiphertext: ciphertext.toString('base64'), + secretIv: iv.toString('base64'), + secretTag: cipher.getAuthTag().toString('base64'), + }; +} + +export function decryptTotpSecret(credential: TotpCredentialLike) { + const decipher = createDecipheriv( + TOTP_CIPHER, + getTotpEncryptionKey(), + Buffer.from(credential.secretIv, 'base64'), + ); + + decipher.setAuthTag(Buffer.from(credential.secretTag, 'base64')); + + return Buffer.concat([ + decipher.update(Buffer.from(credential.secretCiphertext, 'base64')), + decipher.final(), + ]).toString('utf8'); +} + +function normalizeCounter(counter: number | string | null | undefined) { + if (counter === null || counter === undefined) { + return null; + } + + return Number(counter); +} + +export async function getTotpStatus(userId: string) { + const credential = await TotpCredential.findOne({ + where: { userId, enabled: true }, + }); + + return { + enabled: Boolean(credential), + verifiedAt: credential?.verifiedAt ?? null, + lastUsedAt: credential?.lastUsedAt ?? null, + }; +} + +export async function startTotpEnrollment({ + userId, + email, + issuer, +}: { + userId: string; + email: string; + issuer: string; +}) { + const secret = generateTotpSecret(); + const encryptedSecret = encryptTotpSecret(secret); + + await TotpCredential.create({ + userId, + ...encryptedSecret, + issuer, + accountName: email, + enabled: false, + verifiedAt: null, + lastUsedAt: null, + lastUsedCounter: null, + }); + + return { + secret, + otpauthUrl: buildTotpUri({ + issuer, + accountName: email, + secret, + }), + issuer, + accountName: email, + algorithm: DEFAULT_TOTP_ALGORITHM, + digits: DEFAULT_TOTP_DIGITS, + period: DEFAULT_TOTP_PERIOD_SECONDS, + }; +} + +async function findLatestPendingCredential(userId: string) { + return TotpCredential.findOne({ + where: { userId, enabled: false }, + order: [['createdAt', 'DESC']], + }); +} + +async function findEnabledCredential(userId: string) { + return TotpCredential.findOne({ + where: { userId, enabled: true }, + }); +} + +async function verifyCredentialCode(credential: TotpCredential, code: string) { + const secret = decryptTotpSecret(credential); + + return verifyTotpCode({ + secret, + code, + lastUsedCounter: normalizeCounter(credential.lastUsedCounter), + }); +} + +export async function verifyTotpEnrollment(userId: string, code: string) { + const credential = await findLatestPendingCredential(userId); + + if (!credential) { + return { verified: false, reason: 'missing_pending_credential' }; + } + + const verification = await verifyCredentialCode(credential, code); + + if (!verification.verified || verification.counter === null) { + return { verified: false, reason: 'invalid_code' }; + } + + const now = new Date(); + await TotpCredential.update({ enabled: false }, { where: { userId, enabled: true } }); + await credential.update({ + enabled: true, + verifiedAt: now, + lastUsedAt: now, + lastUsedCounter: verification.counter, + }); + + return { verified: true, credential }; +} + +export async function verifyEnabledTotp(userId: string, code: string) { + const credential = await findEnabledCredential(userId); + + if (!credential) { + return { verified: false, reason: 'missing_enabled_credential' }; + } + + const verification = await verifyCredentialCode(credential, code); + + if (!verification.verified || verification.counter === null) { + return { verified: false, reason: 'invalid_code' }; + } + + await credential.update({ + lastUsedAt: new Date(), + lastUsedCounter: verification.counter, + }); + + return { verified: true, credential }; +} + +export async function disableTotp(userId: string, code: string) { + const credential = await findEnabledCredential(userId); + + if (!credential) { + return { disabled: false, reason: 'missing_enabled_credential' }; + } + + const verification = await verifyCredentialCode(credential, code); + + if (!verification.verified) { + return { disabled: false, reason: 'invalid_code' }; + } + + await credential.destroy(); + + return { disabled: true }; +} diff --git a/src/utils/totp.ts b/src/utils/totp.ts new file mode 100644 index 0000000..8184748 --- /dev/null +++ b/src/utils/totp.ts @@ -0,0 +1,197 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the GNU Affero General Public License v3.0 + * See LICENSE file in the project root for full license information + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export const DEFAULT_TOTP_DIGITS = 6; +export const DEFAULT_TOTP_PERIOD_SECONDS = 30; +export const DEFAULT_TOTP_ALGORITHM = 'SHA1'; +export const DEFAULT_TOTP_WINDOW = 1; + +function randomBuffer(length: number) { + const value = randomBytes(length); + + if (Buffer.isBuffer(value)) { + return value; + } + + const fallback = Buffer.from(String(value)); + + if (fallback.length >= length) { + return fallback.subarray(0, length); + } + + return Buffer.concat([fallback, Buffer.alloc(length - fallback.length)]).subarray(0, length); +} + +export function base32Encode(buffer: Buffer) { + let bits = 0; + let value = 0; + let output = ''; + + for (const byte of buffer) { + value = (value << 8) | byte; + bits += 8; + + while (bits >= 5) { + output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + output += BASE32_ALPHABET[(value << (5 - bits)) & 31]; + } + + return output; +} + +export function base32Decode(secret: string) { + const normalized = secret.toUpperCase().replace(/[\s=-]/g, ''); + const bytes: number[] = []; + let bits = 0; + let value = 0; + + for (const char of normalized) { + const index = BASE32_ALPHABET.indexOf(char); + + if (index === -1) { + throw new Error('Invalid base32 secret'); + } + + value = (value << 5) | index; + bits += 5; + + if (bits >= 8) { + bytes.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + + return Buffer.from(bytes); +} + +export function generateTotpSecret(byteLength = 20) { + return base32Encode(randomBuffer(byteLength)); +} + +function counterBuffer(counter: number) { + const buffer = Buffer.alloc(8); + const high = Math.floor(counter / 0x100000000); + const low = counter >>> 0; + + buffer.writeUInt32BE(high, 0); + buffer.writeUInt32BE(low, 4); + + return buffer; +} + +export function getTotpCounter( + timestamp = Date.now(), + periodSeconds = DEFAULT_TOTP_PERIOD_SECONDS, +) { + return Math.floor(timestamp / 1000 / periodSeconds); +} + +export function generateTotpCode({ + secret, + counter = getTotpCounter(), + digits = DEFAULT_TOTP_DIGITS, +}: { + secret: string; + counter?: number; + digits?: number; +}) { + const key = base32Decode(secret); + const digest = createHmac('sha1', key).update(counterBuffer(counter)).digest(); + const offset = digest[digest.length - 1] & 0x0f; + const binary = + ((digest[offset] & 0x7f) << 24) | + ((digest[offset + 1] & 0xff) << 16) | + ((digest[offset + 2] & 0xff) << 8) | + (digest[offset + 3] & 0xff); + const token = binary % 10 ** digits; + + return token.toString().padStart(digits, '0'); +} + +function codesMatch(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + + return timingSafeEqual(leftBuffer, rightBuffer); +} + +export function verifyTotpCode({ + secret, + code, + timestamp = Date.now(), + window = DEFAULT_TOTP_WINDOW, + digits = DEFAULT_TOTP_DIGITS, + periodSeconds = DEFAULT_TOTP_PERIOD_SECONDS, + lastUsedCounter = null, +}: { + secret: string; + code: string; + timestamp?: number; + window?: number; + digits?: number; + periodSeconds?: number; + lastUsedCounter?: number | null; +}) { + if (!new RegExp(`^\\d{${digits}}$`).test(code)) { + return { verified: false, counter: null }; + } + + const currentCounter = getTotpCounter(timestamp, periodSeconds); + + for (let offset = -window; offset <= window; offset += 1) { + const counter = currentCounter + offset; + + if (counter < 0 || (lastUsedCounter !== null && counter <= lastUsedCounter)) { + continue; + } + + const expected = generateTotpCode({ secret, counter, digits }); + + if (codesMatch(expected, code)) { + return { verified: true, counter }; + } + } + + return { verified: false, counter: null }; +} + +export function buildTotpUri({ + issuer, + accountName, + secret, + digits = DEFAULT_TOTP_DIGITS, + periodSeconds = DEFAULT_TOTP_PERIOD_SECONDS, +}: { + issuer: string; + accountName: string; + secret: string; + digits?: number; + periodSeconds?: number; +}) { + const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}`; + const params = new URLSearchParams({ + secret, + issuer, + algorithm: DEFAULT_TOTP_ALGORITHM, + digits: String(digits), + period: String(periodSeconds), + }); + + return `otpauth://totp/${label}?${params.toString()}`; +} diff --git a/tests/factories/totpCredentialFactory.ts b/tests/factories/totpCredentialFactory.ts new file mode 100644 index 0000000..f9fe643 --- /dev/null +++ b/tests/factories/totpCredentialFactory.ts @@ -0,0 +1,22 @@ +import { vi } from 'vitest'; + +export function buildTotpCredential(overrides: any = {}) { + return { + id: 'totp-1', + userId: 'user-1', + secretCiphertext: 'ciphertext', + secretIv: 'iv', + secretTag: 'tag', + issuer: 'Seamless Auth', + accountName: 'test@example.com', + enabled: true, + verifiedAt: new Date(), + lastUsedAt: null, + lastUsedCounter: null, + createdAt: new Date(), + updatedAt: new Date(), + update: vi.fn(), + destroy: vi.fn(), + ...overrides, + }; +} diff --git a/tests/integration/totp/totp.spec.ts b/tests/integration/totp/totp.spec.ts new file mode 100644 index 0000000..4618436 --- /dev/null +++ b/tests/integration/totp/totp.spec.ts @@ -0,0 +1,116 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const issueSessionAndRespondMock = vi.fn(async ({ res }) => { + res.status(200).json({ message: 'Success' }); +}); + +vi.mock('../../../src/services/sessionIssuance.js', () => ({ + issueSessionAndRespond: issueSessionAndRespondMock, +})); + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { createApp } from '../../../src/app.js'; +import { Session } from '../../../src/models/sessions.js'; +import { TotpCredential } from '../../../src/models/totpCredentials.js'; +import { encryptTotpSecret } from '../../../src/services/totpService.js'; +import { generateTotpCode } from '../../../src/utils/totp.js'; +import { buildSession } from '../../factories/sessionFactory.js'; +import { buildTotpCredential } from '../../factories/totpCredentialFactory.js'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + process.env.TOTP_SECRET_ENCRYPTION_KEY = 'test-totp-encryption-key'; +}); + +describe('TOTP routes', () => { + it('starts TOTP enrollment with setup material', async () => { + (getSystemConfig as any).mockResolvedValue({ app_name: 'Seamless Auth' }); + + const res = await request(app).post('/totp/enroll/start'); + + expect(res.status).toBe(200); + expect(res.body.secret).toMatch(/^[A-Z2-7]+$/); + expect(res.body.otpauthUrl).toContain('otpauth://totp/'); + expect(TotpCredential.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + enabled: false, + }), + ); + }); + + it('returns TOTP status without exposing the secret', async () => { + (TotpCredential.findOne as any).mockResolvedValue( + buildTotpCredential({ + verifiedAt: new Date('2026-05-16T12:00:00.000Z'), + lastUsedAt: new Date('2026-05-16T12:30:00.000Z'), + }), + ); + + const res = await request(app).get('/totp/status'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + enabled: true, + verifiedAt: '2026-05-16T12:00:00.000Z', + lastUsedAt: '2026-05-16T12:30:00.000Z', + }); + expect(JSON.stringify(res.body)).not.toContain('secret'); + }); + + it('verifies TOTP during login and issues a session', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const code = generateTotpCode({ secret }); + (TotpCredential.findOne as any).mockResolvedValue( + buildTotpCredential({ + ...encryptTotpSecret(secret), + lastUsedCounter: null, + }), + ); + + const res = await request(app).post('/totp/verify-login').send({ code }); + + expect(res.status).toBe(200); + expect(issueSessionAndRespondMock).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ id: 'user-1' }), + clearExistingCookies: true, + }), + ); + }); + + it('verifies TOTP as MFA and records step-up freshness', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const code = generateTotpCode({ secret }); + const session = buildSession({ stepUpVerifiedAt: null, stepUpMethod: null }); + + (TotpCredential.findOne as any).mockResolvedValue( + buildTotpCredential({ + ...encryptTotpSecret(secret), + lastUsedCounter: null, + }), + ); + (Session.findOne as any).mockResolvedValue(session); + + const res = await request(app).post('/totp/verify-mfa').send({ code }); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + message: 'Success', + fresh: true, + method: 'totp', + }), + ); + expect(session.stepUpMethod).toBe('totp'); + expect(session.save).toHaveBeenCalled(); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 371a66b..c765446 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -37,6 +37,15 @@ vi.mock('../../src/models/credentials.js', () => ({ }, })); +vi.mock('../../src/models/totpCredentials.js', () => ({ + TotpCredential: { + create: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + count: vi.fn(), + }, +})); + vi.mock('../../src/models/sessions.js', () => ({ Session: { create: vi.fn(), diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index c5bb83b..a31bda0 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -5,6 +5,7 @@ vi.unmock('../../../src/models/sessions.js'); vi.unmock('../../../src/models/users.js'); vi.unmock('../../../src/models/systemConfig.js'); vi.unmock('../../../src/models/credentials.js'); +vi.unmock('../../../src/models/totpCredentials.js'); vi.unmock('../../../src/models/magicLinks.js'); describe('models initialization', () => { diff --git a/tests/unit/services/totpService.spec.ts b/tests/unit/services/totpService.spec.ts new file mode 100644 index 0000000..2370139 --- /dev/null +++ b/tests/unit/services/totpService.spec.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TotpCredential } from '../../../src/models/totpCredentials.js'; +import { + encryptTotpSecret, + startTotpEnrollment, + verifyEnabledTotp, + verifyTotpEnrollment, +} from '../../../src/services/totpService.js'; +import { generateTotpCode } from '../../../src/utils/totp.js'; +import { buildTotpCredential } from '../../factories/totpCredentialFactory.js'; + +beforeEach(() => { + vi.clearAllMocks(); + process.env.TOTP_SECRET_ENCRYPTION_KEY = 'test-totp-encryption-key'; +}); + +describe('totpService', () => { + it('starts enrollment with an encrypted pending credential and setup URI', async () => { + const result = await startTotpEnrollment({ + userId: 'user-1', + email: 'test@example.com', + issuer: 'Seamless Auth', + }); + + expect(result.secret).toMatch(/^[A-Z2-7]+$/); + expect(result.otpauthUrl).toContain('otpauth://totp/'); + expect(TotpCredential.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + secretCiphertext: expect.any(String), + secretIv: expect.any(String), + secretTag: expect.any(String), + enabled: false, + }), + ); + }); + + it('verifies pending enrollment and enables the credential', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const encrypted = encryptTotpSecret(secret); + const credential = buildTotpCredential({ + ...encrypted, + enabled: false, + lastUsedCounter: null, + }); + const code = generateTotpCode({ secret }); + + (TotpCredential.findOne as any).mockResolvedValue(credential); + + const result = await verifyTotpEnrollment('user-1', code); + + expect(result.verified).toBe(true); + expect(TotpCredential.update).toHaveBeenCalledWith( + { enabled: false }, + { where: { userId: 'user-1', enabled: true } }, + ); + expect(credential.update).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + verifiedAt: expect.any(Date), + lastUsedAt: expect.any(Date), + lastUsedCounter: expect.any(Number), + }), + ); + }); + + it('rejects reused TOTP counters', async () => { + const secret = 'JBSWY3DPEHPK3PXP'; + const encrypted = encryptTotpSecret(secret); + const code = generateTotpCode({ secret }); + const firstCredential = buildTotpCredential({ + ...encrypted, + lastUsedCounter: null, + }); + + (TotpCredential.findOne as any).mockResolvedValue(firstCredential); + + const firstResult = await verifyEnabledTotp('user-1', code); + const usedCounter = firstCredential.update.mock.calls[0][0].lastUsedCounter; + const replayedCredential = buildTotpCredential({ + ...encrypted, + lastUsedCounter: usedCounter, + }); + + (TotpCredential.findOne as any).mockResolvedValue(replayedCredential); + + const replayResult = await verifyEnabledTotp('user-1', code); + + expect(firstResult.verified).toBe(true); + expect(replayResult.verified).toBe(false); + expect(replayedCredential.update).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/utils/totp.spec.ts b/tests/unit/utils/totp.spec.ts new file mode 100644 index 0000000..8243a40 --- /dev/null +++ b/tests/unit/utils/totp.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { + base32Decode, + base32Encode, + buildTotpUri, + generateTotpCode, + verifyTotpCode, +} from '../../../src/utils/totp.js'; + +describe('totp utils', () => { + it('round-trips base32 encoding', () => { + const value = Buffer.from('hello world'); + + expect(base32Decode(base32Encode(value)).toString('utf8')).toBe('hello world'); + }); + + it('generates RFC 6238 compatible codes', () => { + const secret = base32Encode(Buffer.from('12345678901234567890')); + + expect(generateTotpCode({ secret, counter: 1, digits: 8 })).toBe('94287082'); + }); + + it('verifies codes within the allowed window and rejects replayed counters', () => { + const secret = base32Encode(Buffer.from('12345678901234567890')); + const timestamp = 59_000; + const code = generateTotpCode({ secret, counter: 1, digits: 8 }); + + expect( + verifyTotpCode({ + secret, + code, + timestamp, + digits: 8, + window: 0, + }), + ).toEqual({ verified: true, counter: 1 }); + + expect( + verifyTotpCode({ + secret, + code, + timestamp, + digits: 8, + window: 0, + lastUsedCounter: 1, + }), + ).toEqual({ verified: false, counter: null }); + }); + + it('builds an otpauth URI for authenticator apps', () => { + const uri = buildTotpUri({ + issuer: 'Seamless Auth', + accountName: 'test@example.com', + secret: 'ABC123', + }); + + expect(uri).toContain('otpauth://totp/Seamless%20Auth:test%40example.com'); + expect(uri).toContain('secret=ABC123'); + expect(uri).toContain('issuer=Seamless+Auth'); + }); +});