Hasilkan kata sandi satu kali untuk 2FA di JS menggunakan Web Crypto API

Pendahuluan


Otentikasi dua faktor ada di mana-mana saat ini. Berkat dia, untuk mencuri akun, hanya kata sandi saja tidak cukup. Dan meskipun keberadaannya tidak menjamin bahwa akun Anda tidak akan dihapus untuk menyiasatinya, serangan yang lebih kompleks dan multi-level akan diperlukan. Seperti yang Anda tahu, semakin rumit sesuatu di dunia ini, semakin besar kemungkinan itu tidak akan berhasil.


Saya yakin semua orang yang membaca artikel ini setidaknya pernah menggunakan otentikasi dua faktor (selanjutnya disebut 2FA, frasa yang sangat panjang) dalam kehidupan mereka. Hari ini saya mengundang Anda untuk mencari tahu bagaimana teknologi ini bekerja, yang melindungi akun yang tak terhitung jumlahnya setiap hari.


Tetapi sebagai permulaan, Anda dapat melihat demo dari apa yang akan kita lakukan hari ini.


Dasar-dasarnya


Hal pertama yang perlu disebutkan tentang kata sandi satu kali adalah bahwa mereka terdiri dari dua jenis: HOTP dan TOTP . Yakni, One Time Password berbasis HMAC dan OTP berbasis waktu . TOTP hanyalah add-on untuk HOTP, jadi mari kita bicara tentang algoritma yang lebih sederhana terlebih dahulu.


HOTP dijelaskan oleh spesifikasi RFC4226 . Ini kecil, hanya 35 halaman, dan berisi semua yang Anda butuhkan: deskripsi formal, contoh implementasi dan data uji. Mari kita lihat konsep dasarnya.


Pertama-tama, apa itu HMAC ? HMAC singkatan Kode Otentikasi Pesan Berbasis Hash , atau "kode otentikasi pesan menggunakan fungsi hash" dalam bahasa Rusia. MAC adalah mekanisme untuk memverifikasi pengirim pesan. Algoritma MAC menghasilkan tag MAC menggunakan kunci rahasia yang hanya diketahui oleh pengirim dan penerima. Setelah menerima pesan, Anda dapat membuat tag MAC sendiri dan membandingkan kedua tag tersebut. Jika mereka bertepatan - semuanya beres, tidak ada gangguan dalam proses komunikasi. Sebagai bonus, dengan cara yang sama, Anda dapat memeriksa apakah pesan rusak selama transmisi. Tentu saja, itu tidak akan berfungsi untuk membedakan gangguan dari kerusakan, tetapi fakta korupsi informasi sudah cukup.


Tag MAC


Apa itu hash? Hash adalah hasil dari penerapan fungsi hash ke pesan. Fungsi hash mengambil data Anda dan menjadikannya string dengan panjang tetap. Contoh yang baik adalah fungsi MD5 yang terkenal, yang telah banyak digunakan untuk memverifikasi integritas file.


MAC sendiri bukan algoritma spesifik, tetapi hanya istilah umum. HMAC, pada gilirannya, sudah merupakan implementasi konkret. Lebih khusus lagi, HMAC- X , di mana X adalah salah satu fungsi hash kriptografi. HMAC mengambil dua argumen: kunci rahasia dan pesan, mencampurkannya dengan cara tertentu, menerapkan fungsi hash yang dipilih dua kali, dan mengembalikan tag MAC.


Jika saat ini Anda berpikir sendiri apa yang harus dilakukan dengan kata sandi satu kali - jangan khawatir, kami hampir mencapai poin utama.


Menurut spesifikasi, HOTP dihitung berdasarkan pada dua nilai:


  • K adalah kunci rahasia yang diketahui klien dan server. Panjangnya harus setidaknya 128 bit, dan lebih disukai 160, dan dibuat ketika Anda mengkonfigurasi 2FA.
  • C adalah penghitungnya .

Penghitung adalah nilai 8-byte yang disinkronkan antara klien dan server. Ini diperbarui saat Anda menghasilkan kata sandi baru. Dalam skema HOTP, penghitung sisi klien bertambah setiap kali Anda menghasilkan kata sandi baru. Di sisi server, setiap kali kata sandi berhasil lulus validasi. Karena dimungkinkan untuk membuat kata sandi, tetapi tidak menggunakannya, server memungkinkan nilai penghitung berjalan sedikit di depan dalam jendela yang ditetapkan. Namun, jika Anda terlalu banyak bermain dengan pembuat kata sandi dalam skema HOTP, Anda harus menyinkronkannya lagi.


Jadi Seperti yang mungkin Anda perhatikan, HMAC juga membutuhkan dua argumen. RFC4226 mendefinisikan fungsi pembuatan HOTP sebagai berikut:


HOTP(K,C) = Truncate(HMAC-SHA-1(K,C)) 

Sangat diharapkan, K digunakan sebagai kunci rahasia. Penghitung, pada gilirannya, digunakan sebagai pesan. Setelah fungsi HMAC menghasilkan tag MAC, fungsi Truncate misterius mengeluarkan kata sandi satu kali yang sudah akrab bagi kami, yang Anda lihat di aplikasi generator Anda atau pada token.


Mari kita mulai menulis kode dan menangani sisanya saat ini.


Rencana implementasi


Untuk mendapatkan kata sandi satu kali, kita harus mengikuti langkah-langkah ini.


Implementasi


  • Hasilkan hash HMAC-SHA1 dari parameter K dan C. Ini akan menjadi string 20 byte.
  • Tarik 4 byte dari string ini dengan cara tertentu.
  • Konversikan nilai yang ditarik ke angka dan bagi dengan 10 ^ n, di mana n = jumlah digit dalam kata sandi satu kali (biasanya n = 6). Dan akhirnya, ambil sisa dari divisi ini. Ini akan menjadi kata sandi kami.

Itu tidak terdengar terlalu sulit, bukan? Mari kita mulai dengan generasi hash.


Hasilkan HMAC-SHA1


Ini mungkin yang paling mudah dari langkah-langkah yang tercantum di atas. Kami tidak akan mencoba membuat ulang algoritme sendiri (kami tidak perlu mencoba mengimplementasikan sesuatu dari kriptografi sendiri). Sebagai gantinya, kami akan menggunakan Web Crypto API . Masalah kecilnya adalah bahwa spesifikasi API ini hanya tersedia di bawah Konteks Aman (HTTPS). Bagi kami, ini penuh dengan fakta bahwa kami tidak dapat menggunakannya tanpa mengatur HTTPS di server pengembangan. Sedikit sejarah dan diskusi tentang bagaimana ini adalah keputusan yang tepat dapat ditemukan di sini .


Untungnya, di Firefox Anda dapat menggunakan Web Crypto dalam konteks yang tidak aman, dan Anda tidak perlu menemukan kembali roda atau menyeret pustaka pihak ketiga. Oleh karena itu, untuk keperluan pengembangan demo, saya sarankan menggunakan FF.


API Crypto sendiri didefinisikan di window.crypto.subtle . Jika Anda terkejut dengan namanya, saya kutip dari spesifikasinya:


API disebut SubtleCrypto sebagai cerminan dari fakta bahwa banyak algoritma memiliki persyaratan penggunaan khusus. Hanya ketika persyaratan ini dipenuhi mereka mempertahankan daya tahan mereka.

Mari kita membahas metode yang kita butuhkan. Catatan: semua metode yang disebutkan di sini tidak sinkron dan mengembalikan Promise .


Pertama, kita memerlukan metode importKey , karena kita akan menggunakan kunci pribadi kita, dan tidak menghasilkannya di browser. importKey membutuhkan 5 argumen:


 importKey( format, keyData, algorithm, extractable, usages ); 

Dalam kasus kami:


  • format akan 'raw' , mis. kami akan menyediakan kunci sebagai array byte ArrayBuffer .
  • keyData adalah keyData yang sama. Kami akan segera berbicara tentang cara membuatnya.
  • algorithm , sesuai dengan spesifikasi, akan menjadi HMAC-SHA1 . Argumen ini harus sesuai dengan format HmacImportParams .
  • extractable menjadi false, karena kami tidak punya rencana untuk mengekspor kunci rahasia
  • Dan akhirnya, dari semua usages kita hanya perlu 'sign' .

Kunci rahasia kami akan berupa string acak yang panjang. Di dunia nyata, ini mungkin urutan byte, yang mungkin tidak dapat dicetak, namun, untuk kenyamanan, dalam artikel ini kami akan mempertimbangkan string. Untuk mengubahnya menjadi ArrayBuffer kita akan menggunakan antarmuka TextEncoder . Dengan itu, kuncinya disiapkan dalam dua baris kode:


 const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); 

Sekarang mari kita kumpulkan semuanya:


  const Crypto = window.crypto.subtle; const encoder = new TextEncoder('utf-8'); const secretBytes = encoder.encode(secret); const key = await Crypto.importKey( 'raw', secretBytes, { name: 'HMAC', hash: { name: 'SHA-1' } }, false, ['sign'] ); 

Hebat! Kriptografi disiapkan. Sekarang kita akan berurusan dengan konter dan akhirnya menandatangani pesan.


Menurut spesifikasi, penghitung kami harus sepanjang 8 byte. Kami akan bekerja dengannya lagi, seperti dengan ArrayBuffer . Untuk menerjemahkannya ke dalam formulir ini, kami akan menggunakan trik yang biasanya digunakan di JS untuk menyimpan angka nol di angka atas angka. Setelah itu, kita akan meletakkan setiap byte dalam ArrayBuffer menggunakan DataView . Perlu diingat bahwa dengan spesifikasi untuk semua data biner formatnya adalah big endian .


 function padCounter(counter) { const buffer = new ArrayBuffer(8); const bView = new DataView(buffer); const byteString = '0'.repeat(64); // 8 bytes const bCounter = (byteString + counter.toString(2)).slice(-64); for (let byte = 0; byte < 64; byte += 8) { const byteValue = parseInt(bCounter.slice(byte, byte + 8), 2); bView.setUint8(byte / 8, byteValue); } return buffer; } 

Pad penghitung


Akhirnya, setelah menyiapkan kunci dan penghitungnya, Anda dapat menghasilkan hash! Untuk melakukan ini, kita akan menggunakan fungsi sign dari SubtleCrypto .


 const counterArray = padCounter(counter); const HS = await Crypto.sign('HMAC', key, counterArray); 

Dan untuk ini kami telah menyelesaikan langkah pertama. Pada output, kami mendapatkan nilai yang sedikit misterius yang disebut HS. Dan meskipun ini bukan nama terbaik untuk suatu variabel, itu adalah apa (dan beberapa dari yang berikut) disebut dalam spesifikasi. Kami akan meninggalkan nama-nama ini untuk memudahkan membandingkan kode dengan itu. Apa selanjutnya


Langkah 2: Menghasilkan string 4-byte (Dynamic Truncation)
Biarkan Sbits = DT (HS) // DT, didefinisikan di bawah,
// mengembalikan string 31-bit

DT adalah singkatan dari Dynamic Truncation. Dan inilah cara kerjanya:


 function DT(HS) { // First we take the last byte of our generated HS and extract last 4 bits out of it. // This will be our _offset_, a number between 0 and 15. const offset = HS[19] & 0b1111; // Next we take 4 bytes out of the HS, starting at the offset const P = ((HS[offset] & 0x7f) << 24) | (HS[offset + 1] << 16) | (HS[offset + 2] << 8) | HS[offset + 3] // Finally, convert it into a binary string representation const pString = P.toString(2); return pString; } 

Pemotongan


Perhatikan bagaimana kami menerapkan bitwise AND ke byte pertama HS. 0x7f dalam sistem biner adalah 0b01111111 , jadi pada dasarnya kita hanya membuang bit pertama. Di JS, di sinilah arti dari ungkapan ini berakhir, tetapi dalam bahasa lain itu juga akan menyediakan untuk memotong bit tanda untuk menghilangkan kebingungan antara angka positif / negatif dan menyajikan nomor ini sebagai tidak ditandatangani.


Hampir selesai! Tetap hanya mengubah nilai yang diperoleh dari DT menjadi angka dan meneruskan ke langkah ketiga.


 function truncate(uKey) { const Sbits = DT(uKey); const Snum = parseInt(Sbits, 2); return Snum; } 

Langkah ketiga juga cukup kecil. Yang perlu dilakukan adalah membagi jumlah yang dihasilkan dengan 10 ** ( ) , dan kemudian mengambil sisa dari divisi ini. Jadi, kami memotong angka N terakhir dari angka ini. Menurut spesifikasinya, kode kita harus mampu mengeluarkan setidaknya enam digit kata sandi dan berpotensi 7 dan 8 angka. Secara teoritis, karena ini adalah angka 31-bit, kami dapat mengeluarkan 9 karakter, tetapi dalam kenyataannya, saya pribadi tidak pernah melihat lebih dari 6 karakter. Bagaimana dengan kamu?


Kode untuk fungsi terakhir, yang menggabungkan semua yang sebelumnya, dalam hal ini akan terlihat seperti ini:


 async function generateHOTP(secret, counter) { const key = await generateKey(secret, counter); const uKey = new Uint8Array(key); const Snum = truncate(uKey); // Make sure we keep leading zeroes const padded = ('000000' + (Snum % (10 ** 6))).slice(-6); return padded; } 

Hore! Tetapi bagaimana cara memeriksa sekarang bahwa kode kita sudah benar?


Pengujian


Untuk menguji implementasi, kami akan menggunakan contoh dari RFC. Apendiks D mencakup nilai uji untuk kunci rahasia "12345678901234567890" dan nilai penghitung dari 0 hingga 9. Ada juga hash HMAC yang dihitung dan hasil antara dari fungsi Truncate. Cukup berguna untuk men-debug semua langkah dari algoritma. Ini adalah contoh kecil dari tabel ini (hanya counter dan HOTP yang tersisa):


  Count HOTP 0 755224 1 287082 2 359152 3 969429 ... 

Jika Anda belum menonton demo , sekaranglah saatnya. Anda dapat memasukkan nilai-nilai dari RFC di dalamnya. Dan kembali karena kami memulai TOTP.


Totp


Jadi kami akhirnya sampai pada bagian 2FA yang lebih modern. Ketika Anda membuka penghasil kata sandi satu kali dan melihat penghitung waktu kecil menghitung berapa banyak lagi kode yang valid, itu adalah TOTP. Apa bedanya?


Berbasis waktu berarti bahwa alih-alih nilai statis, waktu saat ini digunakan sebagai penghitung. Atau, lebih tepatnya, "interval" (langkah waktu). Atau bahkan jumlah interval saat ini. Untuk menghitungnya, kami mengambil waktu Unix (jumlah milidetik sejak tengah malam pada 1 Januari 1970 UTC) dan membagi dengan jendela validitas kata sandi (biasanya 30 detik). Server biasanya mentolerir penyimpangan kecil karena sinkronisasi jam tidak sempurna. Biasanya 1 interval bolak-balik tergantung konfigurasi.


Jelas, ini jauh lebih aman daripada skema HOTP. Dalam skema terikat waktu, kode yang valid berubah setiap 30 detik, meskipun belum digunakan. Dalam algoritma asli, kata sandi yang valid ditentukan oleh nilai penghitung saat ini pada jendela server + toleransi. Jika Anda tidak mengautentikasi, kata sandi tidak akan diubah tanpa batas waktu. Anda dapat membaca lebih lanjut tentang TOTP di RFC6238 .


Karena skema berbasis waktu adalah tambahan untuk algoritma asli, kita tidak perlu melakukan perubahan pada implementasi asli. Kami akan menggunakan requestAnimationFrame dan akan memeriksa setiap frame apakah kami masih dalam interval waktu. Jika tidak, buat penghitung baru dan hitung lagi HOTP. Dengan mengabaikan semua kode kontrol, solusinya terlihat seperti ini:


 let stepWindow = 30 * 1000; // 30 seconds in ms let lastTimeStep = 0; const updateTOTPCounter = () => { const timeSinceStep = Date.now() - lastTimeStep * stepWindow; const timeLeft = Math.ceil(stepWindow - timeSinceStep); if (timeLeft > 0) { return requestAnimationFrame(updateTOTPCounter); } timeStep = getTOTPCounter(); lastTimeStep = timeStep; <...update counter and regenerate...> requestAnimationFrame(updateTOTPCounter); } 

Sentuhan Akhir - Dukungan QR Code


Biasanya, ketika kita mengkonfigurasi 2FA, kita memindai parameter awal dengan kode QR. Ini berisi semua informasi yang diperlukan: skema yang dipilih, kunci rahasia, nama akun, nama penyedia, jumlah digit dalam kata sandi.


Dalam artikel sebelumnya, saya berbicara tentang bagaimana Anda dapat memindai kode QR langsung dari layar menggunakan API getDisplayMedia . Berdasarkan materi itu, saya membuat perpustakaan kecil, yang akan kita gunakan sekarang. Pustaka disebut stream-display , dan selain itu kami menggunakan paket jsQR yang luar biasa .


Tautan yang dikodekan QR memiliki format berikut:


 otpauth://TYPE/LABEL?PARAMETERS 

Sebagai contoh:


 otpauth://totp/label?secret=oyu55d4q5kllrwhy4euqh3ouw7hebnhm5qsflfcqggczoafxu75lsagt&algorithm=SHA1&digits=6&period=30 

Saya akan menghilangkan kode yang mengatur proses memulai tangkapan layar dan pengenalan, karena semua ini dapat ditemukan dalam dokumentasi. Alih-alih, inilah cara menguraikan tautan ini:


 const setupFromQR = data => { const url = new URL(data); // drop the "//" and get TYPE and LABEL const [scheme, label] = url.pathname.slice(2).split('/'); const params = new URLSearchParams(url.search); const secret = params.get('secret'); let counter; if (scheme === 'hotp') { counter = params.get('counter'); } else { stepWindow = parseInt(params.get('period'), 10) * 1000; counter = getTOTPCounter(); } } 

Di dunia nyata, kunci rahasia akan menjadi string yang dikodekan base- 32 (!), Karena beberapa byte mungkin tidak dapat dicetak. Tetapi untuk kesederhanaan demonstrasi, kami menghilangkan poin ini. Sayangnya, saya tidak dapat menemukan informasi mengapa base-32 atau hanya format seperti itu. Tampaknya, tidak ada spesifikasi resmi untuk format URL ini, dan format itu sendiri diciptakan oleh Google. Anda dapat membaca sedikit tentang dia di sini.


Untuk menghasilkan kode uji QR, saya sarankan menggunakan FreeOTP .


Kesimpulan


Dan itu saja! Sekali lagi, jangan lupa untuk menonton demo . Ada juga tautan ke repositori dengan kode yang ada di balik itu semua.


Hari ini kami telah membongkar teknologi yang cukup penting yang kami gunakan setiap hari. Saya harap Anda belajar sesuatu yang baru untuk diri Anda sendiri. Artikel ini memakan waktu lebih lama dari yang saya kira. Namun, cukup menarik untuk mengubah spesifikasi kertas menjadi sesuatu yang berfungsi dan sangat familier.


Sampai ketemu lagi!

Source: https://habr.com/ru/post/id462945/


All Articles