
เทียบ Java token ให้รัดกุมด้วย MessageDigest.isEqual พร้อมเทสต์และหมุนคีย์
ระบบรับ webhook แจ้งชำระเงินต้องตรวจความลับก่อนยอมรับคำขอ ไม่ใช่เห็นยอด 1,290 บาทแล้วอัปเดตสถานะทันทีครับ การเทียบ token จึงต้องคิดทั้งผลถูกผิดและข้อมูลที่อาจรั่วผ่านเวลาตอบกลับ ผมจะพาทำตัวตรวจ token แบบ hex ที่ปฏิเสธข้อมูลเสีย และยังรับ token เก่าได้ระหว่างเปลี่ยนชุดโดยมีเทสต์กำกับ
WHY: เทียบความลับต้องดูมากกว่าคำว่าเท่ากัน
Timing side channel คือช่องข้อมูลรั่วผ่านเวลาที่ใช้ทำงาน หากตัวเปรียบเทียบหยุดเมื่อเจอส่วนต่าง เวลาที่ต่างกันอาจกลายเป็นเบาะแสว่าส่วนต้นตรงกันมากแค่ไหน
- เลือกตัวเปรียบเทียบที่มีคำอธิบายเรื่องเวลาในเอกสาร แทนการสมมติว่า
String.equalsหรือArrays.equalsเหมาะกับความลับเพียงเพราะให้ผลเท่ากัน - กำหนดขนาด token ชัดเจน เพื่อแยกข้อมูลผิดรูปแบบออกจากข้อมูลที่พร้อมตรวจ
- รวมกติกา
null, hex เสีย และช่วงรับ token เก่าไว้จุดเดียว
ทำงานยังไง
นึกถึงการเปิดตู้รหัสทีละหลักแล้วฟังเสียง ถ้าเสียงบอกว่าหลักแรกถูก เราก็ได้ข้อมูลเพิ่มทั้งที่ตู้ยังไม่เปิด การเทียบความลับควรลดเบาะแสจากเนื้อหาที่ตรงกัน เหมือนตรวจรหัสทั้งชุดก่อนแจ้งผล ไม่ส่งเสียงเชียร์ทุกหลักครับ
เอกสาร MessageDigest ของ Java 21 ระบุว่า digest เท่ากันเมื่อความยาวเท่ากันและไบต์ทุกตำแหน่งตรงกัน ส่วน Implementation Note ระบุว่าตรวจทุกไบต์ใน digesta และเขียนว่า “The calculation time depends only on the length of digesta.” จากนั้นระบุว่าเวลาไม่ขึ้นกับความยาว digestb หรือเนื้อหาของทั้งสองอาร์เรย์ นี่คือขอบเขตคำอธิบายของเมธอด ไม่ใช่คำรับรองว่า HTTP request ทั้งเส้นทางจะใช้เวลาเท่ากัน
บทความนี้ใช้คำว่า constant-time ในขอบเขตการเทียบที่ไม่ขึ้นกับเนื้อหาตามหมายเหตุข้างต้น เราวางค่าฝั่งเซิร์ฟเวอร์ที่กำหนดขนาดไว้เป็นอาร์กิวเมนต์แรก และคัดกรอง null กับความยาวก่อนเข้าเมธอด ดังนั้นเส้นทางปฏิเสธข้อมูลเสียอาจใช้เวลาต่างกันได้ ขนาด 32 ไบต์เป็นกติกาสาธารณะของตัวอย่าง ไม่ใช่ความลับที่ต้องซ่อน
Hex คือการเขียนหนึ่งไบต์เป็นอักขระฐานสิบหกสองตัว จึงส่ง token 32 ไบต์เป็นข้อความ 64 ตัวได้ เราแปลงกลับด้วย HexFormat.of().parseHex ซึ่งรับทั้งตัวพิมพ์เล็กและใหญ่ตามเอกสาร HexFormat แล้วจึงเทียบไบต์ ไม่ต้องสร้างกติกาเทียบตัวอักษรขึ้นเอง
โค้ดตัวอย่างพร้อมเทสต์
สมมติร้านของคุณมาลีรับ webhook ที่แนบ bearer token หรือความลับที่ผู้ถือใช้ยืนยันสิทธิ์ โค้ดนี้ตรวจเฉพาะ token ไม่ได้ผูก token เข้ากับเนื้อหาแจ้งชำระเงิน การรองรับ current และ previous เป็นรูปแบบการออกแบบช่วงหมุนความลับ ไม่ใช่คุณสมบัติที่ isEqual จัดการให้เอง
บันทึกเป็น Main.java แล้วใช้ javac -encoding UTF-8 Main.java ตามด้วย java -ea Main โดย -ea เปิดการตรวจ assert ที่ใช้เป็นเทสต์
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.HexFormat;
import java.util.Locale;
public class Main {
private static final int TOKEN_BYTES = 32;
private static final HexFormat HEX = HexFormat.of();
static byte[] generateToken() {
byte[] token = new byte[TOKEN_BYTES];
new SecureRandom().nextBytes(token);
return token;
}
static boolean matches(byte[] expected, byte[] supplied) {
if (expected == null || supplied == null) return false;
if (expected.length != TOKEN_BYTES
|| supplied.length != TOKEN_BYTES) return false;
return MessageDigest.isEqual(expected, supplied);
}
static boolean accepts(String tokenHex,
byte[] current, byte[] previous) {
if (tokenHex == null || tokenHex.length() != TOKEN_BYTES * 2) {
return false;
}
final byte[] supplied;
try {
supplied = HEX.parseHex(tokenHex);
} catch (IllegalArgumentException invalidHex) {
return false;
}
// Evaluate both slots even when current matches.
boolean currentMatch = matches(current, supplied);
boolean previousMatch = matches(previous, supplied);
return currentMatch | previousMatch;
}
public static void main(String[] args) {
// Fixed fixtures for tests only; never use these as real secrets.
byte[] current = HEX.parseHex("a1".repeat(TOKEN_BYTES));
byte[] previous = HEX.parseHex("b2".repeat(TOKEN_BYTES));
String currentHex = HEX.formatHex(current);
String previousHex = HEX.formatHex(previous);
assert accepts(currentHex, current, previous);
assert accepts(previousHex, current, previous);
assert accepts(currentHex.toUpperCase(Locale.ROOT), current, previous);
assert !accepts("c3".repeat(TOKEN_BYTES), current, previous);
assert !accepts(null, current, previous);
assert !accepts("", current, previous);
assert !accepts("00".repeat(31), current, previous);
assert !accepts("00".repeat(33), current, previous);
assert !accepts("z1".repeat(TOKEN_BYTES), current, previous);
assert !matches(null, current);
assert !matches(null, null);
assert !matches(current, null);
assert !matches(new byte[31], current);
assert !matches(current, new byte[33]);
assert !matches(new byte[0], new byte[0]);
byte[] changedFirst = current.clone();
changedFirst[0] ^= 1;
byte[] changedLast = current.clone();
changedLast[TOKEN_BYTES - 1] ^= 1;
assert !matches(current, changedFirst);
assert !matches(current, changedLast);
assert matches(current, current.clone());
// Remove previous after the agreed rotation window.
assert accepts(currentHex, current, null);
assert !accepts(previousHex, current, null);
assert !accepts(currentHex, null, null);
byte[] generated = generateToken();
assert generated.length == TOKEN_BYTES;
assert accepts(HEX.formatHex(generated), generated, null);
String payment = "ร้านมาลี | 2026-10-23 | 1290 บาท";
System.out.println(payment + " | token accepted: "
+ accepts(currentHex, current, previous));
System.out.println("OK: all assertions passed");
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 (JDK 21) โดยเปิด -ea แล้ว เทสต์ผ่านทั้งหมดและพิมพ์บรรทัดสุดท้ายว่า OK: all assertions passed ครับ
generateToken ใช้ SecureRandom.nextBytes สร้างไบต์สุ่มตามเอกสาร SecureRandom ซึ่งกำหนดให้ผลสุ่มแข็งแรงทางการเข้ารหัส ตัวอย่างไม่ตั้ง seed จากเวลา ส่วนค่าซ้ำ a1 และ b2 มีไว้ให้เทสต์อ่านง่ายเท่านั้น
สังเกตว่าเราคำนวณผลทั้งสองช่องก่อนรวมด้วย | จึงไม่ข้ามการตรวจ previous เมื่อ current ผ่าน แต่กรณี previous เป็น null ยังออกจาก matches เร็วได้ เราจึงไม่อ้างว่าการหมุนคีย์ทั้งกระบวนการใช้เวลาคงที่ เทสต์เหล่านี้ยืนยันผลลัพธ์และกติกาปฏิเสธข้อมูล ไม่ได้พิสูจน์คุณสมบัติด้านเวลาด้วยการจับนาฬิกา
จุดที่คนพลาดบ่อย
- ใช้ตัวเทียบทั่วไปกับความลับ: เปลี่ยนจุดตรวจ token เป็น
MessageDigest.isEqualหลังแปลงเป็นไบต์ เอกสารที่ให้มาไม่ได้ระบุวิธีหยุดของตัวเทียบทั่วไป - ปล่อยข้อมูลเสียไปถึง parser: ตรวจ
nullและความยาวก่อน แล้วจับIllegalArgumentExceptionจาก hex ผิดรูปแบบ ตัวอย่างปฏิเสธทั้งช่องว่างและรูปแบบมีตัวคั่น ไม่แอบtrimให้ผ่าน - ให้ค่าว่างยืนยันตัวตนได้: wrapper นี้บังคับ 32 ไบต์ทั้งสองฝั่ง แม้สองอาร์เรย์ว่างจะดูเหมือนเท่ากันก็ไม่เข้าเกณฑ์ token ของเรา อย่าโยนการกำหนดนโยบายให้เมธอดเปรียบเทียบ
- รับ previous ตลอดไป: กำหนดวันจบช่วงเปลี่ยนผ่านและถอดค่าเก่าออกจริง โค้ดมีเทสต์ว่าหลังส่ง previous เป็น
nullแล้ว token เก่าต้องไม่ผ่าน - คิดว่าเทียบ token แล้วตรวจเนื้อหาครบ: HMAC คือตัวตรวจความถูกต้องของข้อความที่อาศัยคีย์ร่วม หากระบบใช้ HMAC ต้องคำนวณจากข้อความและคีย์ตามสัญญาของระบบก่อน แล้วเทียบผลที่คาดหวังกับลายเซ็นที่ถอด hex แล้ว
isEqualไม่ได้คำนวณ HMAC ให้ และไม่ป้องกันการส่งคำขอเดิมซ้ำให้เอง
ใช้ตรงไหนในงานจริง
- ตรวจ token ของ webhook ภายในองค์กร โดยกำหนดรูปแบบ header และขนาด token ในสัญญาระหว่างระบบให้ตรงกัน
- รองรับการเปลี่ยนความลับของระบบคู่เชื่อมต่อ เก็บ current กับ previous ชั่วคราว แล้วทดสอบวันถอนค่าเก่าในแผนปล่อยงาน
- ใช้เป็นขั้นเปรียบเทียบลายเซ็น HMAC หลังคำนวณลายเซ็นที่คาดหวังแล้ว โดยกำหนดขนาดตามอัลกอริทึมของระบบ ไม่ยึด 32 ไบต์ของตัวอย่างทุกกรณี
- ทำชุดเทสต์การยืนยันตัวตนให้ครอบคลุมข้อมูลขาดหาย ความยาวผิด และไบต์ต่างที่ต้นหรือท้าย เก็บ log เฉพาะผลตรวจและรหัสคำขอ หลีกเลี่ยงพิมพ์ความลับเต็มค่า
เทียบกับทางเลือกอื่น
| approach | ดีตรงไหน | ระวังอะไร |
|---|---|---|
String.equals | เหมาะกับการเทียบข้อความทั่วไป | ไม่ใช้เป็นหลักประกันเรื่องเวลาของการตรวจความลับ |
Arrays.equals | สื่อเจตนาการเทียบอาร์เรย์ทั่วไปชัด | อย่าอนุมานคำรับรองด้านเวลาจากผล boolean |
MessageDigest.isEqual | มี Implementation Note เรื่องเวลาใน JDK 21 | เวลาอิงความยาวอาร์กิวเมนต์แรก และต้องออกแบบการรับข้อมูลเอง |
| เขียนลูปเทียบเอง | ปรับเงื่อนไขได้เอง | พิสูจน์พฤติกรรมเวลาได้ยาก เลือก API ที่มีเอกสารรองรับก่อน |
Recap
- แยกความถูกต้องของผลเทียบออกจากข้อมูลที่อาจรั่วผ่านเวลา
- วางค่าฝั่งเซิร์ฟเวอร์ที่มีขนาดกำหนดไว้เป็นอาร์กิวเมนต์แรก
- ถอด hex เป็นไบต์ก่อนเทียบด้วย
MessageDigest.isEqual - ปฏิเสธ
nullความยาวผิด ค่าว่าง และ hex เสียตามกติกา - สร้าง token จริงด้วย
SecureRandomแทนค่าคงที่ในเทสต์ - กำหนดเส้นตายของ previous และทดสอบว่าถอนแล้วใช้ไม่ได้
- รันด้วย
java -ea Mainและไม่อ้างว่า assert พิสูจน์เรื่องเวลา