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 };
|