Mesin TOTP MFA Native & Enkripsi AES-256-GCM
Implementasi autentikasi dua faktor RFC 6238 menggunakan modul crypto bawaan Node.js dengan penyimpanan secret terenkripsi dan kode pemulihan sekali pakai.
Membaca sekitar 2 menit
Implementasi autentikasi dua faktor RFC 6238 menggunakan modul crypto bawaan Node.js dengan penyimpanan secret terenkripsi dan kode pemulihan sekali pakai.
Secret 160-bit dienkripsi dengan AES-256-GCM sebelum masuk ke database, kode OTP diverifikasi menggunakan timingSafeEqual, dan 10 kode pemulihan disimpan sebagai hash SHA-256.
Menulis sendiri fungsi HOTP/TOTP sepanjang kurang dari 200 baris membebaskan proyek dari ketergantungan paket npm tua yang rawan supply-chain attack.
Masalahnya dulu
Menambahkan fitur Two-Factor Authentication (TOTP) sering kali dilakukan dengan menginstal library pihak ketiga yang membawa puluhan sub-dependensi usang. Lebih parah lagi, banyak tutorial menyimpan secret TOTP di kolom database sebagai teks biasa. Jika suatu hari backup database bocor, penyerang bisa langsung menghasilkan kode 6 digit yang valid untuk semua akun termasuk Super Admin.
Merakit RFC 6238 murni dan mengenkripsi secret
Karena algoritma TOTP (RFC 6238) dan HOTP (RFC 4226) sejatinya hanya membutuhkan encoding Base32 dan HMAC-SHA1 atas counter waktu 30 detik, saya menulis implementasi murninya di common/utils/totp.ts hanya menggunakan modul node:crypto. Sebelum secret disimpan ke kolom mfa_secret di database, nilainya dienkripsi menggunakan AES-256-GCM dengan IV acak 12-byte dan authentication tag. Selain itu, sistem membangkitkan 10 kode pemulihan (recovery codes) yang di-hash dengan SHA-256 ber-salt untuk berjaga-jaga jika ponsel pengguna hilang.
Kunci rahasia MFA di database wajib diperlakukan setara dengan password: jangan pernah biarkan ia terbaca dalam bentuk telanjang.
Implementasi di ngodingan ini
Berikut adalah kode inti pembangkitan HOTP/TOTP serta enkripsi AES-256-GCM di common/utils/totp.ts:
function hotp(secret: string, counter: number) {
const key = base32Decode(secret);
const counterBuffer = Buffer.alloc(8);
counterBuffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", key).update(counterBuffer).digest();
const offset = digest[digest.length - 1] & 0x0f;
const binary = (digest.readUInt32BE(offset) & 0x7fffffff) % 10 ** TOTP_DIGITS;
return binary.toString().padStart(TOTP_DIGITS, "0");
}
export function encryptMfaSecret(secret: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", getEncryptionKey(), iv);
const ciphertext = Buffer.concat([cipher.update(secret, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [
"v1",
iv.toString("base64url"),
tag.toString("base64url"),
ciphertext.toString("base64url"),
].join(".");
}Jebakan dan detail yang harus dijaga
- Menggunakan timingSafeEqual saat mencocokkan kode: Baik kode TOTP 6 digit maupun hash recovery code dibandingkan menggunakan Buffer dan timingSafeEqual agar waktu eksekusi CPU tidak membocorkan petunjuk karakter yang benar.
- Menyimpan secret sementara di tiket cookie terenkripsi saat setup: Saat pengguna baru memindai QR code dan belum memasukkan 6 digit konfirmasi, secret belum ditulis ke tabel users melainkan disimpan di cookie HttpOnly mfa-setup berumur pendek.