src/auth/totp.service.ts
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.
Methods |
constructor()
|
|
Defined in src/auth/totp.service.ts:18
|
| decryptSecret | ||||||
decryptSecret(stored: string)
|
||||||
|
Defined in src/auth/totp.service.ts:91
|
||||||
|
Reverse of encryptSecret. Throws on tamper / wrong key.
Parameters :
Returns :
string
|
| encryptSecret | ||||||
encryptSecret(plaintext: string)
|
||||||
|
Defined in src/auth/totp.service.ts:78
|
||||||
|
Encrypt a base32 TOTP secret. Returns
Parameters :
Returns :
string
|
| Async generateEnrollment | ||||||
generateEnrollment(email: string)
|
||||||
|
Defined in src/auth/totp.service.ts:33
|
||||||
|
Generate a fresh base32 secret + the otpauth:// URI + a PNG QR code
encoded as a data URL ready for an
Parameters :
Returns :
Promise<literal type>
|
| verifyCode |
verifyCode(plaintextSecret: string, code: string)
|
|
Defined in src/auth/totp.service.ts:52
|
|
Verify a 6-digit code against a plaintext secret. Returns true on match within the configured time window.
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');
}
}