File

src/auth/totp.service.ts

Description

Time-based One-Time Password service (RFC 6238) backing 2FA enrollment, verification, and disable.

Secrets are encrypted at rest with AES-256-GCM. The encryption key is derived from TOTP_ENCRYPTION_KEY (preferred) or JWT_SECRET (fallback). Rotating the key invalidates every existing TOTP enrollment — issue a new key only when you're prepared to ask everyone to re-enroll.

Index

Methods

Constructor

constructor()

Methods

decryptSecret
decryptSecret(stored: string)

Reverse of encryptSecret. Throws on tamper / wrong key.

Parameters :
Name Type Optional
stored string No
Returns : string
encryptSecret
encryptSecret(plaintext: string)

Encrypt a base32 TOTP secret. Returns iv:tag:ciphertext (hex).

Parameters :
Name Type Optional
plaintext string No
Returns : string
Async generateEnrollment
generateEnrollment(email: string)

Generate a fresh base32 secret + the otpauth:// URI + a PNG QR code encoded as a data URL ready for an <img src=…> in the browser. The secret returned here is the plaintext — the caller is responsible for encrypting + persisting via encryptSecret.

Parameters :
Name Type Optional
email string No
Returns : Promise<literal type>
verifyCode
verifyCode(plaintextSecret: string, code: string)

Verify a 6-digit code against a plaintext secret. Returns true on match within the configured time window.

Parameters :
Name Type Optional
plaintextSecret string No
code string No
Returns : boolean
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as crypto from 'crypto';
import * as QRCode from 'qrcode';

/**
 * Time-based One-Time Password service (RFC 6238) backing 2FA enrollment,
 * verification, and disable.
 *
 * Secrets are encrypted at rest with AES-256-GCM. The encryption key is
 * derived from `TOTP_ENCRYPTION_KEY` (preferred) or `JWT_SECRET` (fallback).
 * Rotating the key invalidates every existing TOTP enrollment — issue a
 * new key only when you're prepared to ask everyone to re-enroll.
 */
@Injectable()
export class TotpService {
  private readonly logger = new Logger(TotpService.name);
  private readonly issuer = 'FleetCommand TMS';

  constructor() {
    // Slightly looser window than the otplib default (1 step) to tolerate
    // small clock drift between the user's phone and our server. Two steps
    // = ±60 s tolerance.
    authenticator.options = { window: 1, step: 30 };
  }

  /**
   * Generate a fresh base32 secret + the otpauth:// URI + a PNG QR code
   * encoded as a data URL ready for an `<img src=…>` in the browser.
   * The secret returned here is the *plaintext* — the caller is
   * responsible for encrypting + persisting via {@link encryptSecret}.
   */
  async generateEnrollment(email: string): Promise<{
    secret: string;
    otpauthUrl: string;
    qrDataUrl: string;
  }> {
    const secret = authenticator.generateSecret();
    const otpauthUrl = authenticator.keyuri(email, this.issuer, secret);
    const qrDataUrl = await QRCode.toDataURL(otpauthUrl, {
      errorCorrectionLevel: 'M',
      width: 256,
      margin: 1,
    });
    return { secret, otpauthUrl, qrDataUrl };
  }

  /**
   * Verify a 6-digit code against a plaintext secret. Returns true on
   * match within the configured time window.
   */
  verifyCode(plaintextSecret: string, code: string): boolean {
    if (!plaintextSecret || !code) return false;
    const cleaned = code.replace(/\s/g, '');
    if (!/^\d{6}$/.test(cleaned)) return false;
    try {
      return authenticator.verify({ token: cleaned, secret: plaintextSecret });
    } catch (err) {
      this.logger.warn(`TOTP verify failed: ${(err as Error).message}`);
      return false;
    }
  }

  // ── Encryption / decryption ──────────────────────────────────────────

  private getKey(): Buffer {
    const raw = process.env.TOTP_ENCRYPTION_KEY || process.env.JWT_SECRET;
    if (!raw) {
      throw new Error(
        'TOTP_ENCRYPTION_KEY (or JWT_SECRET) must be set to enable 2FA',
      );
    }
    // Derive a stable 32-byte key via SHA-256.
    return crypto.createHash('sha256').update(raw).digest();
  }

  /** Encrypt a base32 TOTP secret. Returns `iv:tag:ciphertext` (hex). */
  encryptSecret(plaintext: string): string {
    const iv = crypto.randomBytes(12);
    const key = this.getKey();
    const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
    const ciphertext = Buffer.concat([
      cipher.update(plaintext, 'utf8'),
      cipher.final(),
    ]);
    const tag = cipher.getAuthTag();
    return `${iv.toString('hex')}:${tag.toString('hex')}:${ciphertext.toString('hex')}`;
  }

  /** Reverse of {@link encryptSecret}. Throws on tamper / wrong key. */
  decryptSecret(stored: string): string {
    const parts = stored.split(':');
    if (parts.length !== 3) {
      throw new BadRequestException('Corrupted TOTP secret');
    }
    const [ivHex, tagHex, ctHex] = parts;
    const key = this.getKey();
    const decipher = crypto.createDecipheriv(
      'aes-256-gcm',
      key,
      Buffer.from(ivHex, 'hex'),
    );
    decipher.setAuthTag(Buffer.from(tagHex, 'hex'));
    const plain = Buffer.concat([
      decipher.update(Buffer.from(ctHex, 'hex')),
      decipher.final(),
    ]);
    return plain.toString('utf8');
  }
}

results matching ""

    No results matching ""