Autentikasi multifaktor (multi-factor authentication, MFA) menaikkan syarat masuk dari sesuatu yang diketahui menjadi sesuatu yang juga dimiliki, dan kata sandi sekali pakai (one-time password, OTP) adalah bentuk faktor kedua yang paling mudah diterapkan. Kendati demikian, alur OTP dan reset kata sandi sering ditambahkan sebagai lapisan tanpa menegakkan kontraknya, sehingga keamanan kembali ke lapisan pertama yang memang sudah bocor. Topik ini memodelkan replay, race, batas percobaan, dan token reset pada verifikator cacat dan verifikator aman, lalu merangkum syarat yang membuat alur tersebut benar-benar menambah ketahanan.
OTP sebagai faktor kedua#
OTP umumnya berupa enam digit yang diturunkan dari rahasia bersama dan waktu, sebagaimana algoritma time-based one-time password (TOTP) yang digunakan aplikasi authenticator, dan kodenya hanya sah dalam jendela waktu yang pendek. Kekuatannya tidak terletak pada panjang kodenya, melainkan pada kontraknya, yaitu kode berumur pendek, sekali pakai, dan terbatas jumlah percobaannya. Verifikator yang tidak menegakkan salah satu butir kontrak itu mengubah faktor kedua menjadi formalitas, misalnya kode yang tetap diterima berulang-ulang atau yang dapat dicoba tanpa batas hingga enam digitnya tertebak.
Kontrak itu pula yang menjadi daftar periksa pada saat menguji alur MFA. Serangan terhadap OTP jarang menyerang kriptografinya, dan lebih sering menyerang keadaan di sekitarnya, yaitu kode yang tidak dibuang setelah digunakan, verifikasi yang dapat berlindas, dan jalur pemulihan akun yang melompati faktor kedua sama sekali. Skrip berikut memodelkan ketiganya dengan kode tetap 042713 dan cap waktu simulasi yang terkunci, sehingga keluarannya dapat diulang secara identik.
Replay, kedaluwarsa, dan batas percobaan#
Skrip berikut membandingkan dua verifikator. Versi cacat membandingkan kode tanpa membuangnya, tidak memeriksa masa berlaku, dan mencatat percobaan secara terpisah dari pemeriksaannya. Versi aman menandai kode terpakai, menolak kode yang melewati masa berlakunya, membatasi percobaan, dan mengunci seluruh pemeriksaan di balik threading.Lock.
"""Memodelkan kelemahan alur OTP dan token reset kata sandi.
Versi cacat membandingkan kode tanpa membuangnya, tanpa masa berlaku,
dan dengan penghitung percobaan yang diperiksa serta dicatat secara
terpisah sehingga rentan race. Versi aman menandai kode terpakai,
memeriksa masa berlaku, dan mengunci seluruh pemeriksaan di balik
threading.Lock. OTP dan seluruh cap waktu dibuat tetap agar keluaran
deterministik. Percobaan hanya diarahkan ke akun lab di dalam berkas.
"""
import secrets
import threading
KODE = "042713"
TERBIT = 1000.0 # kode diterbitkan pada t=1000 detik (simulasi)
MASA_BERLAKU = 300.0 # detik
MAKS_PERCOBAAN = 3
class VerifikatorCacat:
"""Tanpa pembuangan kode, tanpa kedaluwarsa, dan pencatatan terpisah."""
def __init__(self):
self.percobaan = 0
def periksa(self):
"""Pemeriksaan batas tanpa kunci: membaca penghitung bersama."""
return self.percobaan < MAKS_PERCOBAAN
def catat(self):
"""Pencatatan terjadi belakangan, setelah jendela race terbuka."""
self.percobaan += 1
def verifikasi(self, kode, waktu):
"""waktu diabaikan: versi cacat tidak memeriksa masa berlaku."""
lolos = self.periksa() and kode == KODE
self.catat()
return "DITERIMA" if lolos else "ditolak (batas percobaan)"
class VerifikatorAman:
"""Single-use, berbatas waktu, dan berbatas percobaan secara atomik."""
def __init__(self):
self.terbit = TERBIT
self.terpakai = False
self.percobaan = 0
self.kunci = threading.Lock()
def verifikasi(self, kode, waktu):
with self.kunci:
self.percobaan += 1
if self.percobaan > MAKS_PERCOBAAN:
return "ditolak (batas percobaan)"
if waktu > self.terbit + MASA_BERLAKU:
return "ditolak (kedaluwarsa)"
if self.terpakai:
return "ditolak (sudah terpakai)"
if kode != KODE:
return "ditolak (kode salah)"
self.terpakai = True
return "DITERIMA"
def gelombang(verifikator, jumlah):
"""Kirim `jumlah` permintaan hampir bersamaan dengan jadwal terkunci.
Pada versi cacat, dua Barrier memaksa seluruh pemeriksaan selesai
sebelum pencatatan pertama, persis jendela race di dunia nyata.
Pada versi aman, urutan permintaan disusun dengan Event agar
keluaran deterministik, sedangkan verifikasinya tetap atomik.
"""
hasil = [None] * jumlah
sinyal = None
if isinstance(verifikator, VerifikatorCacat):
pintu_periksa = threading.Barrier(jumlah)
pintu_catat = threading.Barrier(jumlah)
def kerja(i):
pintu_periksa.wait()
lolos = verifikator.periksa()
hasil[i] = "DITERIMA" if lolos else "ditolak"
pintu_catat.wait()
if lolos:
verifikator.catat()
else:
sinyal = [threading.Event() for _ in range(jumlah + 1)]
def kerja(i):
sinyal[i].wait()
hasil[i] = verifikator.verifikasi(KODE, TERBIT + 50)
sinyal[i + 1].set()
benang = [threading.Thread(target=kerja, args=(i,)) for i in range(jumlah)]
for benang_satu in benang:
benang_satu.start()
if sinyal is not None:
sinyal[0].set()
for benang_satu in benang:
benang_satu.join()
return hasil
print("== Replay dan kedaluwarsa (verifikasi berurutan) ==")
cacat = VerifikatorCacat()
print("cacat: verifikasi t=1050 ->", cacat.verifikasi(KODE, 1050.0))
print("cacat: verifikasi ulang t=1100 ->", cacat.verifikasi(KODE, 1100.0), "(kode yang sama diterima lagi)")
print("cacat: verifikasi t=5000 ->", cacat.verifikasi(KODE, 5000.0), "(tanpa pemeriksaan masa berlaku)")
aman = VerifikatorAman()
print("aman : verifikasi t=1050 ->", aman.verifikasi(KODE, 1050.0), "(kode ditandai terpakai)")
print("aman : verifikasi ulang t=1100 ->", aman.verifikasi(KODE, 1100.0))
aman_kedaluwarsa = VerifikatorAman()
print("aman : contoh baru, verifikasi t=5000 ->", aman_kedaluwarsa.verifikasi(KODE, 5000.0))
print()
print("== Lima permintaan hampir bersamaan (batas 3 percobaan) ==")
cacat_serentak = VerifikatorCacat()
hasil_cacat = gelombang(cacat_serentak, 5)
print("cacat:", ", ".join(hasil_cacat),
f"(semua pemeriksaan membaca penghitung 0, penghitung berakhir {cacat_serentak.percobaan})")
hasil_aman = gelombang(VerifikatorAman(), 5)
print("aman :", ", ".join(hasil_aman))
print()
print("== Batas percobaan pada versi aman (kode salah berulang) ==")
aman_tebakan = VerifikatorAman()
for urutan in range(1, 5):
print(f"percobaan {urutan}: {aman_tebakan.verifikasi('000000', 1050.0)}")
print()
print("== Token reset kata sandi ==")
print("cacat: token berurutan per permintaan ->", ", ".join(f"reset-{n}" for n in (1001, 1002, 1003)))
token_aman = secrets.token_urlsafe(32)
print(f"aman : secrets.token_urlsafe(32) -> panjang {len(token_aman)} karakter, tak dapat diprediksi")== Replay dan kedaluwarsa (verifikasi berurutan) ==
cacat: verifikasi t=1050 -> DITERIMA
cacat: verifikasi ulang t=1100 -> DITERIMA (kode yang sama diterima lagi)
cacat: verifikasi t=5000 -> DITERIMA (tanpa pemeriksaan masa berlaku)
aman : verifikasi t=1050 -> DITERIMA (kode ditandai terpakai)
aman : verifikasi ulang t=1100 -> ditolak (sudah terpakai)
aman : contoh baru, verifikasi t=5000 -> ditolak (kedaluwarsa)
== Lima permintaan hampir bersamaan (batas 3 percobaan) ==
cacat: DITERIMA, DITERIMA, DITERIMA, DITERIMA, DITERIMA (semua pemeriksaan membaca penghitung 0, penghitung berakhir 5)
aman : DITERIMA, ditolak (sudah terpakai), ditolak (sudah terpakai), ditolak (batas percobaan), ditolak (batas percobaan)
== Batas percobaan pada versi aman (kode salah berulang) ==
percobaan 1: ditolak (kode salah)
percobaan 2: ditolak (kode salah)
percobaan 3: ditolak (kode salah)
percobaan 4: ditolak (batas percobaan)
== Token reset kata sandi ==
cacat: token berurutan per permintaan -> reset-1001, reset-1002, reset-1003
aman : secrets.token_urlsafe(32) -> panjang 43 karakter, tak dapat diprediksiTiga baris pertama keluaran menunjukkan kontrak yang runtuh pada versi cacat. Kode 042713 diterima pada t=1050, diterima lagi pada t=1100 karena tidak pernah ditandai terpakai, dan masih diterima pada t=5000 karena masa berlaku tidak pernah diperiksa, padahal sejak penerbitan sudah berlalu 4000 detik, lebih dari sepuluh kali lipat masa berlaku 300 detik. Pada versi aman, penerimaan pertama menandai kode terpakai sehingga penggunaan ulangnya tertolak, dan contoh baru menolak kode yang terlambat dengan alasan kedaluwarsa. Bagian ketiga memperlihatkan batas percobaan yang bekerja, yaitu tiga tebakan salah masih dijawab, sedangkan percobaan keempat ditolak sebelum kodenya dinilai.
Race pada verifikasi bersamaan#
Celah yang halus terletak pada jarak antara pemeriksaan dan pencatatan. Versi cacat membaca penghitung percobaan dan menambahkannya pada langkah yang terpisah, sehingga beberapa permintaan yang tiba hampir bersamaan sempat membaca nilai yang sama sebelum ada satu pun pencatatan. Skrip memaksakan kejadian itu dengan dua pagar threading.Barrier, yaitu seluruh permintaan menyelesaikan pemeriksaannya lebih dahulu, baru kemudian mencatat, persis seperti jendela race yang terbuka ketika pemeriksaan dan pencatatan tidak atomik. Keluarannya memperlihatkan lima permintaan DITERIMA meskipun batasnya tiga, dan penghitung berakhir di angka 5.
Versi aman menjalankan pemeriksaan dan penandaan sebagai satu kesatuan di balik kunci, sehingga permintaan kedua selalu membaca keadaan yang sudah ditandai. Pada lima permintaan serentak, hanya satu yang diterima, dua permintaan berikutnya tertolak karena kodenya sudah terpakai, dan dua sisanya tertolak karena batas percobaannya terlampaui. Pola perbaikannya sama dengan kunci eksklusif yang menutup race condition pada modul Kontrol Akses yang Rusak, yaitu pemeriksaan dan penggunaan keadaan yang harus tak terpisahkan.
Reset kata sandi sebagai pintu masuk#
Alur reset kata sandi adalah faktor kedua yang sering dilupakan, karena alurnya bertujuan membantu pengguna yang justru kehilangan aksesnya. Jalur yang cacat menerbitkan token reset yang dapat diprediksi, misalnya nomor berurutan atau turunan waktu seperti reset-1001 pada keluaran skrip, sehingga penyerang yang menemukan polanya dapat mereset akun siapa pun tanpa menyentuh OTP sama sekali. Celah semacam ini membatalkan seluruh MFA, karena jalur pemulihan memang dirancang untuk melompati kata sandi lama, dan keamanannya kemudian bertumpu sepenuhnya pada token yang lemah itu.
Token reset wajib memenuhi kontrak yang sama dengan OTP, yaitu dibangkitkan dari pembangkit acak kriptografis seperti secrets.token_urlsafe, sekali pakai, berumur pendek, dan dicabut begitu digunakan atau begitu kata sandi baru disetorkan. Kebiasaan tambahan yang murah dan efektif adalah mengirim notifikasi kepada pemilik akun setiap kali reset diminta, karena permintaan massal yang tidak diminta merupakan sinyal serangan yang paling awal. OWASP menyarankan seluruh ketentuan ini pada Multifactor Authentication Cheat Sheet dan Authentication Cheat Sheet, sedangkan NIST SP 800-63B menilai saluran pengiriman kode, misalnya SMS, berisiko lebih tinggi dibandingkan dengan aplikasi authenticator.
Kesalahan umum#
- Memperlakukan OTP sebagai kata sandi kedua yang tetap berlaku. Kode yang tidak dibuang setelah verifikasi dapat diputar ulang (replay) selama kodenya masih tersimpan, sehingga kode wajib ditandai terpakai pada saat yang sama dengan penerimaannya.
- Menghitung percobaan setelah verifikasi selesai. Jarak antara pemeriksaan dan pencatatan membuka jendela race yang membuat batas percobaan dilampaui, sehingga keduanya digabung dalam satu bagian atomik.
- Menyimpan jawaban pemulihan, misalnya pertanyaan keamanan, sebagai faktor kedua yang setara OTP. Jawaban semacam itu sering dapat ditebak atau bocor, sehingga faktor keduanya tetap berupa kepemilikan yang benar.
- Menerbitkan token reset yang berumur panjang atau dapat digunakan berulang. Token yang bocor dari email atau log tetap hidup berhari-hari, sehingga masa berlakunya pendek dan penggunaannya menonaktifkan token itu sendiri.
Ringkasan#
- Kekuatan OTP terletak pada kontraknya, yaitu berumur pendek, sekali pakai, dan berbatas percobaan, bukan pada panjang kodenya.
- Kode yang tidak dibuang dapat diputar ulang, dan verifikasi yang tidak atomik membiarkan beberapa permintaan lolos melampaui batas percobaan.
- Token reset yang dapat diprediksi membatalkan seluruh MFA, sehingga token pemulihan wajib acak, sekali pakai, dan berumur pendek.
- Pemeriksaan dan pencatatan keadaan verifikasi wajib menjadi satu kesatuan atomik di balik kunci.
Referensi#
- Multifactor Authentication Cheat Sheet — OWASP — Panduan pemilihan faktor kedua dan penanganan pemulihan akun yang aman.
- Authentication Cheat Sheet — OWASP — Panduan alur reset kata sandi, batas percobaan, dan ketahanan autentikasi.
- NIST SP 800-63B — NIST — Ketentuan verifikator dan penilaian saluran pengiriman kode sekali pakai.