Hello! Petang lain, dalam perjalanan pulang ke rumah, saya memutuskan untuk menyemak peti surat. Saya tidak maksudkan peti masuk e-mel saya, tetapi kotak sebenar sekolah lama di mana posmen meletakkan huruf fizikal. Dan yang sangat mengejutkan saya, saya menjumpai sampul surat di sana dengan sesuatu di dalamnya! Semasa membukanya, saya meluangkan beberapa saat berharap bahawa ia adalah surat tertangguh selama beberapa dekad daripada Hogwarts. Tetapi kemudian saya terpaksa turun semula ke Bumi, sebaik sahaja saya menyedari bahawa ia adalah surat "dewasa" yang membosankan daripada bank. Saya membaca sekilas teks dan menyedari bahawa bank "digital sahaja" saya untuk kanak-kanak hebat telah diperoleh oleh pemain terbesar di pasaran tempatan. Dan sebagai tanda permulaan baharu, mereka menambahkan ini pada sampul surat:
Bersama dengan arahan tentang cara menggunakannya.
Jika anda seperti saya, dan tidak pernah menjumpai sekeping inovasi teknologi sedemikian, izinkan saya berkongsi apa yang saya pelajari daripada surat itu: pemilik baharu memutuskan untuk menguatkuasakan dasar keselamatan daripada syarikat mereka, yang bermaksud bahawa semua pengguna akaun mulai sekarang akan mendayakan MFA (pujian untuk itu, btw). Dan peranti yang anda boleh lihat di atas menjana token sekali 6 digit panjang yang digunakan sebagai faktor kedua semasa log masuk ke akaun bank anda. Pada asasnya, cara yang sama seperti apl seperti Authy, Google Authenticator atau 2FAS berfungsi, tetapi dalam bentuk fizikal.
Jadi, saya mencubanya dan proses log masuk berjalan lancar: peranti menunjukkan kepada saya kod 6 digit, saya memasukkannya dalam apl perbankan saya, dan ini membuatkan saya masuk. Hooray! Tetapi kemudian sesuatu menarik perhatian saya: bagaimana perkara ini berfungsi? Tidak ada cara ia disambungkan ke internet entah bagaimana, tetapi ia berjaya menjana kod yang betul yang diterima oleh pelayan bank saya. Hm... Bolehkah ia mempunyai kad SIM atau sesuatu yang serupa di dalamnya? Tidak boleh!
Menyedari bahawa hidup saya tidak akan pernah sama, saya mula tertanya-tanya tentang aplikasi yang saya nyatakan di atas (Authy dan rakan-rakan)? Penyelidik dalaman saya telah disedarkan, jadi saya menukar telefon saya ke dalam mod kapal terbang dan, yang mengejutkan saya, menyedari bahawa ia berfungsi dengan baik di luar talian: mereka terus menjana kod yang diterima oleh pelayan aplikasi. Menarik!
Tidak pasti tentang anda, tetapi saya sentiasa mengambil mudah aliran token sekali sahaja dan tidak pernah benar-benar memikirkannya (terutamanya kerana hari ini jarang telefon saya tidak mempunyai internet melainkan Saya sedang melakukan beberapa pengembaraan luar), jadi itulah punca kejutan saya. Jika tidak, ia masuk akal dari sudut pandangan keselamatan untuk berfungsi dengan cara ini, kerana proses penjanaan adalah tempatan semata-mata, sangat selamat daripada pelakon luar. Tetapi bagaimana ia berfungsi?
Nah, teknologi moden seperti Google atau ChatGPT memudahkan untuk mencari jawapan dengan mudah. Tetapi masalah teknikal ini kelihatan menyeronokkan kepada saya, jadi memutuskan untuk mencubanya dan menyelesaikannya sendiri terlebih dahulu.
Mari kita mulakan dengan apa yang kita ada:
Bahagian pengesahan pelayan membayangkan bahawa pelayan mesti dapat menjana kod yang sama seperti peranti luar talian untuk membandingkannya. Hm..itu boleh membantu.
Pemerhatian lanjut saya terhadap "mainan" baharu saya membawa lebih banyak penemuan:
Satu-satunya penjelasan logik yang boleh saya hasilkan ialah kod ini mempunyai jangka hayat tertentu. Saya ingin menceritakan kisah saya cuba mengira tempohnya dalam fesyen "1-2-3-...-N", tetapi ia tidak benar: Saya mendapat petunjuk besar daripada apl seperti Authy and Co, tempat saya melihat TTL 30 saat. Temuan yang bagus, mari tambahkan ini pada senarai fakta yang diketahui.
Mari kita ringkaskan keperluan yang kita ada setakat ini:
Baiklah, tetapi persoalan utama masih belum terjawab: bagaimana mungkin apl luar talian boleh menjana nilai yang sepadan dengan apl lain? Apakah persamaan mereka?
Jika anda menyukai alam semesta Lord of the Rings, anda mungkin masih ingat bagaimana Bilbo bermain teka-teki dengan Gollum, dan menyelesaikannya:
Perkara ini dimakan semua benda:
Burung, binatang, pokok, bunga;
Menggigit besi, menggigit keluli;
Mengisar batu keras untuk dimakan;
Membunuh raja, merosakkan bandar,
Dan mengalahkan gunung yang tinggi ke bawah.
Amaran spoiler, tetapi En. Baggins bernasib baik dan mendapat jawapan yang betul secara tidak sengaja - "Masa!". Percaya atau tidak, tetapi ini adalah jawapan kepada teka-teki kami juga: mana-mana 2 (atau lebih) apl mempunyai akses kepada masa yang sama selagi apl tersebut mempunyai jam terbenam di dalamnya. Yang terakhir tidak menjadi masalah pada hari ini, dan peranti yang dimaksudkan cukup besar untuk memuatkannya. Lihat sekeliling, dan kemungkinan masa pada jam tangan anda, telefon bimbit, TV, ketuhar dan jam di dinding adalah sama. Kami menyukai sesuatu di sini, nampaknya kami telah menemui asas untuk pengkomputeran OTP (kata laluan satu kali)!
Bergantung pada masa mempunyai set cabarannya sendiri:
Mari kita atasinya satu persatu:
Ok, ini telah diselesaikan, jadi mari cuba laksanakan versi pertama algoritma kami menggunakan masa sebagai asas. Memandangkan kami berminat dengan keputusan 6 digit, nampaknya pilihan bijak untuk bergantung pada cap masa dan bukannya tarikh yang boleh dibaca manusia. Jom mulakan dari situ:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Menurut dokumen Go, .Unix() kembali
bilangan saat berlalu sejak 1 Januari 1970 UTC.
Ini yang dicetak ke terminal:
Current timestamp: 1733691162
Itu permulaan yang baik, tetapi jika kita menjalankan semula kod itu, nilai cap masa akan berubah, sementara kita ingin memastikan ia stabil selama 30 saat. Nah, sekeping kek, mari bahagikannya dengan 30 dan gunakan nilai itu sebagai asas:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Jom jalankan:
Current timestamp: 1733691545 Base: 57789718
Dan sekali lagi:
Current timestamp: 1733691552 Base: 57789718
Nilai asas kekal sama. Mari tunggu sebentar, dan jalankan semula:
Current timestamp: 1733691571 Base: 57789719
Nilai asas telah berubah, apabila tetingkap 30 saat telah berlalu - bagus!
Jika logik "bahagi dengan 30" tidak masuk akal, izinkan saya menerangkannya dengan contoh mudah:
Saya harap ia lebih masuk akal sekarang.
Walau bagaimanapun, belum semua keperluan dipenuhi, kerana kami memerlukan hasil 6 digit, manakala asas semasa mempunyai 8 digit pada hari ini, tetapi pada satu ketika pada masa hadapan ia mungkin mencapai 9 titik digit, dan seterusnya . Baiklah, mari kita gunakan satu lagi helah matematik mudah: biar bahagikan asas dengan 1 000 000, dan dapatkan bakinya, yang akan sentiasa mempunyai tepat 6 digit, kerana peringatan boleh berupa sebarang nombor dari 0 hingga 999 999, tetapi tidak lebih besar:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Bahagian fmt.Sprintf("d", kod) menambahkan sifar pendahuluan sekiranya nilai kod kami mempunyai kurang daripada 6 digit. Sebagai contoh, 1234 akan ditukar kepada 001234.
Keseluruhan kod untuk siaran ini boleh didapati di sini.
Mari jalankan kod ini:
Current timestamp: 1733691162
Baiklah, kami mendapat kod 6 digit kami, hore! Tetapi ada sesuatu yang tidak sesuai di sini, bukan? Jika saya memberi anda kod ini, dan anda akan menjalankannya pada masa yang sama seperti yang saya lakukan, anda akan mendapat kod yang sama, seperti saya. Ini tidak menjadikannya kata laluan sekali sahaja yang selamat, bukan? Inilah keperluan baharu:
Sudah tentu, beberapa perlanggaran tidak dapat dielakkan, jika kami mempunyai melebihi 1 juta pengguna, kerana ini adalah nilai unik maksimum yang mungkin bagi setiap 6 digit. Tetapi ini adalah perlanggaran yang jarang berlaku dan tidak dapat dielakkan secara teknikal, bukan kecacatan reka bentuk algoritma seperti yang kita ada sekarang.
Saya tidak fikir sebarang helah matematik yang bijak akan membantu kami di sini sendiri: jika kami memerlukan hasil yang berasingan bagi setiap pengguna, kami memerlukan keadaan khusus pengguna untuk merealisasikannya. Sebagai jurutera dan, pada masa yang sama, pengguna banyak perkhidmatan, kami tahu bahawa untuk memberikan akses kepada API mereka, perkhidmatan bergantung pada kunci peribadi, yang unik bagi setiap pengguna. Mari perkenalkan kunci peribadi untuk kes penggunaan kami juga untuk membezakan antara pengguna.
Logik mudah untuk menjana kunci peribadi sebagai integer antara 1 000 000 dan 999 999 999:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Kami menggunakan peta pkDb sebagai cara untuk menghalang pendua antara kunci peribadi, dan jika pendua telah dikesan, kami menjalankan logik penjanaan sekali lagi sehingga kami mendapat hasil yang unik.
Mari jalankan kod ini untuk mendapatkan sampel kunci peribadi:
Current timestamp: 1733691545 Base: 57789718
Mari kita gunakan kunci peribadi ini dalam logik penjanaan kod kami untuk memastikan kami mendapat hasil yang berbeza bagi setiap kunci peribadi. Memandangkan kunci persendirian kami adalah daripada jenis integer, perkara paling mudah yang boleh kami lakukan ialah menambahkannya pada nilai asas dan mengekalkan algoritma yang tinggal seperti sedia ada:
Current timestamp: 1733691552 Base: 57789718
Mari pastikan ia menghasilkan hasil yang berbeza untuk kunci peribadi yang berbeza:
Current timestamp: 1733691571 Base: 57789719
Hasilnya kelihatan seperti yang kami mahu dan jangkakan:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Berfungsi seperti daya tarikan! Ini bermakna kunci persendirian harus disuntik ke dalam peranti yang menjana kod sebelum ia dihantar kepada pengguna seperti saya: itu tidak sepatutnya menjadi masalah sama sekali bagi bank.
Sudahkah kita selesai sekarang? Nah, hanya jika kita berpuas hati dengan senario buatan yang kita gunakan. Jika anda pernah mendayakan MFA untuk mana-mana perkhidmatan / tapak web yang anda gunakan akaun, anda mungkin perasan bahawa sumber web meminta anda mengimbas kod QR dengan apl faktor kedua pilihan anda (Authy, Google Authenticator, 2FAS, dsb. ) yang akan memasukkan kod rahsia ke dalam apl anda dan mula menjana kod 6 digit mulai saat itu. Sebagai alternatif, anda boleh memasukkan kod secara manual.
Saya membawa perkara ini untuk menyebut bahawa adalah mungkin untuk melihat format kunci peribadi sebenar yang digunakan dalam industri. Biasanya rentetan berkod Base32 sepanjang 16-32 aksara yang kelihatan seperti ini:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Seperti yang anda lihat, ini agak berbeza daripada kunci peribadi integer yang kami gunakan dan pelaksanaan semasa algoritma kami tidak akan berfungsi jika kami ingin menukar kepada format ini. Bagaimanakah kita boleh menyesuaikan logik kita?
Mari kita mulakan dengan pendekatan mudah: kod kami tidak akan dikompil, kerana baris ini:
Current timestamp: 1733691162
kerana pk adalah daripada jenis rentetan mulai sekarang. Jadi mengapa kita tidak menukarnya kepada integer? Walaupun terdapat cara yang lebih elegan dan berprestasi untuk melakukannya, berikut adalah perkara paling mudah yang saya hasilkan:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Ini sangat diilhamkan oleh pelaksanaan Java hashCode() untuk jenis data String, yang menjadikannya cukup baik untuk senario kami.
Berikut ialah logik yang dilaraskan:
Current timestamp: 1733691545 Base: 57789718
Berikut ialah output terminal:
Current timestamp: 1733691552 Base: 57789718
Kod 6 digit yang bagus, bagus. Mari tunggu untuk sampai ke tetingkap kali seterusnya dan jalankannya semula:
Current timestamp: 1733691571 Base: 57789719
Hm...ia berfungsi, tetapi kod itu, pada asasnya adalah kenaikan nilai sebelumnya, yang tidak baik, kerana dengan cara ini OTP boleh diramal, dan mempunyai nilainya serta mengetahui masanya, ia sangat mudah untuk mula menjana nilai yang sama tanpa perlu mengetahui kunci peribadi. Berikut ialah pseudokod mudah untuk penggodaman ini:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
di mana keepWithinSixDigits akan memastikan bahawa selepas 999 999 nilai seterusnya ialah 000 000 dan seterusnya untuk mengekalkan nilai dalam kemungkinan had 6 digit.
Seperti yang anda lihat, ia adalah kecacatan keselamatan yang serius. Mengapa ia berlaku? Jika kita melihat logik pengiraan asas, kita akan melihat bahawa ia bergantung pada 2 faktor:
Cincang menghasilkan nilai yang sama untuk kunci yang sama, jadi nilainya adalah malar. Bagi semasa / 30 , ia mempunyai nilai yang sama selama 30 saat, tetapi apabila tetingkap telah berlalu, nilai seterusnya akan menjadi kenaikan yang sebelumnya. Kemudian asas % 1_000_000 berkelakuan seperti yang kita lihat. Pelaksanaan kami sebelum ini (dengan kunci peribadi sebagai integer) mempunyai kelemahan yang sama, tetapi kami tidak menyedarinya - kekurangan ujian untuk dipersalahkan.
Kita perlu mengubah arus / 30 menjadi sesuatu untuk menjadikan perubahan nilainya lebih ketara.
Terdapat pelbagai cara untuk mencapainya, dan beberapa helah matematik yang hebat wujud di luar sana, tetapi untuk tujuan pendidikan mari kita utamakan kebolehbacaan penyelesaian yang akan kita gunakan: mari kita ekstrak semasa / 30 ke dalam pangkalan pembolehubah yang berasingan dan masukkan ia ke dalam logik pengiraan cincang:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Dengan cara ini, walaupun asas akan berubah dengan 1 setiap 30 saat, selepas digunakan dalam logik fungsi hash(), berat beza akan meningkat disebabkan oleh siri pendaraban yang dilakukan.
Berikut ialah contoh kod yang dikemas kini:
Current timestamp: 1733691162
Jom jalankan:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Boom! Bagaimanakah kita mendapat nilai tolak di sini? Nah, nampaknya kita kehabisan julat int64, jadi ia mengehadkan nilai kepada tolak dan bermula semula - rakan Java saya sudah biasa dengan ini daripada tingkah laku hashCode(). Penyelesaiannya mudah: mari kita ambil nilai mutlak daripada hasilnya, maka tanda tolak diabaikan:
Current timestamp: 1733691545 Base: 57789718
Berikut ialah keseluruhan sampel kod dengan pembetulan:
Current timestamp: 1733691552 Base: 57789718
Jom jalankan:
Current timestamp: 1733691571 Base: 57789719
Mari kita jalankan sekali lagi untuk memastikan bahawa nilai OTP diedarkan sekarang:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds: base := current / 30 fmt.Println("Base: ", base) // makes sure it has only 6 digits: code := base % 1_000_000 // adds leading zeros if necessary: formattedCode := fmt.Sprintf("%06d", code) fmt.Println("Code: ", formattedCode)
Bagus, akhirnya penyelesaian yang baik!
Sebenarnya, itulah saat saya menghentikan proses pelaksanaan manual saya, kerana saya berseronok dan mempelajari sesuatu yang baharu. Walau bagaimanapun, ia bukan penyelesaian terbaik mahupun penyelesaian yang saya akan buat secara langsung. Antara lain, ia mempunyai kelemahan yang besar: seperti yang anda lihat, logik kami sentiasa berurusan dengan nombor yang besar disebabkan oleh logik pencincangan dan nilai cap masa, yang bermaksud bahawa sangat tidak mungkin kami dapat menjana hasil yang bermula dengan 1 atau lebih sifar: cth., 012345 , 001234, dsb., walaupun ia sah sepenuhnya. Disebabkan itu, kami adalah 100 000 nilai kemungkinan pendek, iaitu 10% daripada bilangan kemungkinan hasil algoritma - kemungkinan perlanggaran adalah lebih tinggi dengan cara ini. Tidak hebat!
Saya tidak akan mendalami pelaksanaan yang digunakan dalam aplikasi sebenar, tetapi bagi mereka yang ingin tahu, saya akan berkongsi dua RFC yang patut dilihat:
Dan berikut ialah pelaksanaan pseudokod yang akan berfungsi dengan cara yang dimaksudkan berdasarkan RFC di atas:
Current timestamp: 1733692423 Base: 57789747 Code: 789747
Seperti yang anda lihat, kami sangat hampir dengan itu, tetapi algoritma asal menggunakan pencincangan yang lebih maju (HMAC-SHA1 dalam contoh ini) dan melakukan beberapa operasi bitwise untuk menormalkan output.
Walau bagaimanapun, ada satu lagi perkara yang ingin saya bincangkan sebelum kita menyebutnya sehari: keselamatan. Saya amat menggalakkan anda supaya tidak melaksanakan logik penjanaan OTP sendiri, kerana terdapat banyak perpustakaan di luar sana yang telah melakukannya untuk kami. Ruang untuk kesilapan adalah besar, dan ia adalah jarak yang dekat dengan kelemahan yang akan ditemui dan dieksploitasi oleh pelakon jahat di luar sana.
Walaupun anda mendapat logik penjanaan dengan betul dan akan menutupnya dengan ujian, terdapat perkara lain yang boleh menjadi salah. Sebagai contoh, pada pendapat anda, berapa banyak yang diperlukan untuk memaksa kod 6 digit? Mari bereksperimen:
// gets current timestamp: current := time.Now().Unix() fmt.Println("Current timestamp: ", current)
Mari jalankan kod ini:
Current timestamp: 1733691162
Dan sekali lagi:
// gets current timestamp current := time.Now().Unix() fmt.Println("Current timestamp: ", current) // gets a number that is stable within 30 seconds base := current / 30 fmt.Println("Base: ", base)
Seperti yang anda lihat, ia mengambil masa kira-kira 70 ms untuk meneka kod melalui gelung for-forcing yang mudah. Itu 400 kali lebih cepat daripada seumur hidup OTP! Pelayan apl/tapak web yang menggunakan mekanisme OTP perlu menghalangnya dengan, sebagai contoh, tidak menerima kod baharu untuk 5 atau 10 saat seterusnya selepas 3 percubaan yang gagal. Dengan cara ini penyerang hanya mendapat 18 atau 9 percubaan yang sepadan dalam tetingkap 30 saat, yang tidak mencukupi untuk kumpulan 1 juta nilai yang mungkin.
Dan ada lagi perkara seperti ini yang mudah terlepas pandang. Jadi, izinkan saya ulangi: jangan bina ini dari awal, tetapi bergantung pada penyelesaian sedia ada.
Apa pun, saya harap anda mempelajari sesuatu yang baharu hari ini, dan logik OTP tidak akan menjadi misteri untuk anda mulai saat ini. Selain itu, jika pada satu ketika dalam hidup, anda perlu menjadikan peranti luar talian anda menjana beberapa nilai menggunakan algoritma yang boleh dihasilkan semula, anda mempunyai idea yang baik untuk bermula.
Terima kasih atas masa yang anda luangkan membaca siaran ini, dan bergembiralah! =)
P.S. Terima e-mel setelah saya menerbitkan siaran baharu - langgan di sini
P.P.S. Seperti kanak-kanak lain yang hebat, saya telah mencipta akaun Bluesky sejak kebelakangan ini, jadi tolong bantu saya untuk menjadikan suapan saya lebih menyeronokkan =)
Atas ialah kandungan terperinci Menyahmistikan OTP: logik di sebalik penjanaan token luar talian. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!