All files / src/utils totp-crypto.js

100% Statements 29/29
100% Branches 10/10
100% Functions 3/3
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101                                    20x   20x 20x 20x                   20x     28x 17x 17x 4x         13x 13x                       20x 20x   17x   17x   17x                         10x   10x 2x         8x 8x 8x 8x   8x 7x   7x   5x         20x  
/**
 * TOTP secret encryption/decryption using AES-256-GCM.
 *
 * Encrypted format: "<iv_hex>:<ciphertext_hex>:<tag_hex>"
 *   - IV:         12 bytes (96 bits), randomly generated per encryption
 *   - Ciphertext: same length as plaintext, hex-encoded
 *   - Auth tag:   16 bytes (128 bits), hex-encoded
 *
 * Key source: TOTP_ENCRYPTION_KEY environment variable (64 hex chars = 32 bytes)
 *
 * Usage:
 *   const { encryptSecret, decryptSecret } = require('./totp-crypto');
 *   const stored = encryptSecret(rawBase32Secret);
 *   const raw    = decryptSecret(stored);
 */
 
'use strict';
 
const crypto = require('node:crypto');
 
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const KEY_HEX_LENGTH = 64; // 32 bytes × 2 hex chars each
 
// ─── Lazy key resolution ──────────────────────────────────────────
//
// Resolve (and validate) the TOTP_ENCRYPTION_KEY env var the first
// time an encrypt/decrypt call is made, not at module load. This
// isolates config errors to the TOTP endpoints — missing/invalid key
// fails those calls with a clear 500 instead of taking down the
// entire Express API on startup.
 
let cachedKey = null;
 
function getKey() {
  if (cachedKey) return cachedKey;
  const rawKey = process.env.TOTP_ENCRYPTION_KEY;
  if (!rawKey || rawKey.length !== KEY_HEX_LENGTH) {
    throw new Error(
      `TOTP_ENCRYPTION_KEY must be exactly ${KEY_HEX_LENGTH} hex characters (32 bytes). ` +
        `Got: ${rawKey ? rawKey.length : 0} characters.`,
    );
  }
  cachedKey = Buffer.from(rawKey, 'hex');
  return cachedKey;
}
 
// ─── encryptSecret ────────────────────────────────────────────────
 
/**
 * Encrypts a TOTP secret string using AES-256-GCM.
 *
 * @param {string} plaintext - The raw TOTP secret (e.g. BASE32 string).
 * @returns {string} Encrypted string in the format "iv:ciphertext:tag" (hex).
 */
function encryptSecret(plaintext) {
  const iv = crypto.randomBytes(IV_BYTES);
  const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
 
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
 
  const tag = cipher.getAuthTag();
 
  return [iv.toString('hex'), ciphertext.toString('hex'), tag.toString('hex')].join(':');
}
 
// ─── decryptSecret ────────────────────────────────────────────────
 
/**
 * Decrypts an encrypted TOTP secret string produced by encryptSecret().
 *
 * @param {string} encryptedString - "iv:ciphertext:tag" (hex).
 * @returns {string} The original plaintext.
 * @throws {Error} If the format is invalid, the key is wrong, or the data is tampered.
 */
function decryptSecret(encryptedString) {
  const parts = encryptedString.split(':');
 
  if (parts.length !== 3) {
    throw new Error(
      `Invalid encrypted format. Expected "iv:ciphertext:tag", got ${parts.length} part(s).`,
    );
  }
 
  const [ivHex, ciphertextHex, tagHex] = parts;
  const iv = Buffer.from(ivHex, 'hex');
  const ciphertext = Buffer.from(ciphertextHex, 'hex');
  const tag = Buffer.from(tagHex, 'hex');
 
  const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
  decipher.setAuthTag(tag);
 
  const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
 
  return plaintext.toString('utf8');
}
 
// ─── Exports ──────────────────────────────────────────────────────
 
module.exports = { encryptSecret, decryptSecret };