การออกแบบตัวเข้ารหัสให้แข็งแกร่ง
สำหรับทุกวันนี้ ถ้าถามว่าใช้อัลกอริทึมอะไรดีในการเข้ารหัส ถ้าเอาง่าย ๆ คงเลือกแบบ AES กัน โดยโค้ดตัวอย่างจะเป็นภาษา Kotlin
แต่เราเข้ารหัสข้อมูลเหมือน ๆ เดิมด้วย key เดิมซ้ำ ๆ เราก็จะได้ ข้อมูลที่เข้ารหัสแล้วหน้าตาแบบเดิม และนี่อาจทำให้เห็น pattern ของข้อมูลที่เข้ารหัสได้ แต่ว่าถ้าเราใช้ key ใหม่ทุกครั้งก็ข้ามบทความนี้ไปเลย
ไม่ได้พูดถึง mode การเข้ารหัสในบทความนี้ ผมบอกได้แค่ว่า
- บาง mode เน้นเรื่อง performance
- บาง mode สามารถถอดรหัสหลาย ๆ block พร้อม ๆ กันได้
- บาง mode เน้นเรื่องความปลอดภัย
- บาง mode ไม่สามารถถอดรหัส block ต่อ ๆ ไป ได้ถ้า block ก่อน ๆ หน้าพัง
- บาง mode จะสามารถใช้ IV ได้
- และบอกไม่ได้ว่า padding กับ nopadding ต่างกันยังไงนะ 555
แล้วทำยังไงให้ไม่เห็น pattern
IV (initialization vector) เป็นวิธีการหนึ่งที่ถูกสร้างมาเพื่อแก้ปัญหาเหล่านี้ ซึ่ง IV ควรจะถูกสร้างใหม่แบบสุ่ม ๆ ทุก ๆ ครั้งที่ใช้งาน หรือทุก ๆ กี่ block ของข้อมูลก็ว่าจะไปซึ่งถ้าจะ implement ให้มันสุ่มทุก n block อาจจะยากไปหน่อย (คนเขียนทำไม่ได้และขี้เกียจ 555) และควรใช้ SecureRandom
หรืออะไรก็ได้ที่การสุ่มค่านั้น ๆ ได้มาตรฐานว่าเกิดการสุ่มจริง และถ้า IV ไม่ได้เกิดจากการสุ่มก็ไม่ต่างอะไรกับการเข้ารหัสด้วย AES แบบใช้ key อย่างเดียว
fun generateIv(random: SecureRandom): ByteArray {
val key = ByteArray(16)
random.nextBytes(key)
return key
}
เนื่องจาก key ถูกออกแบบมาให้เป็น private แล้วคือห้ามหลุดไปข้างนอกเด็ดขาด IV จึงยอมให้เป็น public ได้ ซึ่งโดยปกติเราจะแปะ IV ที่ข้อมูลที่เข้ารหัสแล้ว และมักแปะไว้ข้างหน้าของข้อมูลที่เข้ารหัส ทั้งนี้ใครจะออกแบบว่าจะแปะตรงไหนก็แล้วแต่จะตกลงกันเลย
fun encrypt(original: ByteArray): ByteArray {
val iv = generateIv()
val aes = AES(key, iv)
val encrypted = aes.encrypt(original)
return iv + encrypted
}
ซึ่งนั่นแปลว่าการถอด เราต้องแกะ IV ออกมาจากข้อมูลก่อน
fun decrypt(encrypted: ByteArray): ByteArray {
val iv = encrypted.sliceArray(0..15)
val encrypted = bytes.sliceArray(16..bytes.lastIndex)
val aes = AES(key, iv)
return aes.decrypt(encrypted)
}
การออกแบบ key
สมมุติว่าต้องสร้าง key ขนาด 256 bits เรามักจะใช้ string ยาว 32 ตัวอักษร ซึ่งเป็นเรื่องที่ไม่ควร เราไม่ควรใช้แค่ printable characters เพราะจะทำให้ความน่าจะเป็นของ key ลดลงมาก เพราะ non-printable characters จะไม่ถูกรวมในความน่าจะเป็นของ key
val key = "!WVPm`RJBa.4Xwo@z_*\"9qj8NbHe LT2"
val bytes = key.toByteArray()
ให้ดีถ้าต้องเก็บเป็น config และต้องเป็น stirng เท่านั้น อาจจะเก็บเป็น hex code ยาว 64 ตัวอักษร แล้วแปลงเป็น ByteArray
แทนจะปลอดภัยกว่า และใช้ความน่าจะเป็นได้ทั้ง 256 bits
val key = "AA BB CC DD EE..."
val bytes = hexToBytes(key)
เจาะลึกลงไป
ว่าทำไมแค่เราเสก key จาก printable characters ให้ครบ 256 bits ไม่พอใช้ ถ้าเราดูตามตาราง ascii
- 0-31 และ 127 รวม 33 ตัว เป็น control characters ซึ่งแสดงเป็นตัวอักษรไม่ได้ มันเอาไว้เป็น command ไปหาคอมพิวเตอร์ เช่นเลื่อน cursor สั่งลบตัวอักษรก่อนหน้า ฯลฯ ชุดนี้ไม่นับ
- 32–47, 58–64, 91–96, 123-126 รวม 16+7+6+4 = 33 ตัว เป็นอักขระพิเศษ
- 48–57 รวม 10 ตัว เป็นตัวเลข 0–9
- 65–90 เป็นตัวพิมพ์ใหญ่ รวม 26 ตัว
- 97–122 เป็นตัวพิมพ์เล็ก รวม 26 ตัว
รวมทั้งสิ้น 95 ตัว อักษร นั่นเท่ากับว่า key ที่ยาว 256 bits นั้นจะเท่ากับ log2(95³²) ซึ่งเท่ากับประมาณ 210 bits เท่านั้น
แล้วถ้าเราออกแบบ key แต่ใช้ พิมพ์เล็ก (26 รูปแบบ) และตัวเลข (10 รูปแปป) จะเท่ากับ log2(36³²) = ประมาณ 165 bits เท่านั้น
ปล.
เคยมีงานวิจัยบอกว่า dev ทำให้ได้หมดแหละ แต่ขอให้บอก เพราะหลาย ๆ ครั้ง dev ก็ไม่รู้ว่าควรใช้อัลกอลทึมอะไรแบบไหนในการเข้ารหัส ในการสร้าง key หรืออาจจะเลือกหยิบผิดเช่น 3DES ที่เก่าไปแล้ว
ทั้งนี้ทีม audit และ security ควรจะเข้ามากำกับดูแลตั้งแต่เนิ่น ๆ เพราะของพวกนี้ส่วนใหญ่ทำครั้งเดียวและพอขึ้น production ไปแล้วจะแก้ไขได้ยาก
ถ้าทำไปแล้ว ก็ migrate ได้ แต่การ migrate ไม่ใช่เรื่องง่ายอยู่แล้ว
อ้างอิง
… ไม่มีอะ 555 อ่านมานานแล้วหาไม่ได้
ถ้าผิดโปรดบอก