
เช็ก SHA-256 ของไฟล์ดาวน์โหลดด้วย Java ก่อนเอาเข้าระบบ
ไฟล์ติดตั้งดาวน์โหลดเสร็จและชื่อเวอร์ชันถูกต้อง ยังไม่พอให้เอาเข้าระบบบริษัททันทีครับ ข่าวไฟล์ปล่อยตัวถูกสลับช่วงนี้ทำให้ขั้นตอนตรวจไฟล์ก่อนใช้งานมีความหมายมากขึ้น เราจะใช้ Java คำนวณ SHA-256 แล้วเทียบกับค่าที่คาดหวัง เพื่อให้ขั้นตอนติดตั้งหยุดได้เมื่อไฟล์ไม่ตรงกัน โดยต้องเลือกแหล่งของค่าอ้างอิงให้เชื่อถือได้ด้วย
WHY: ตรวจไฟล์ก่อนปล่อยให้มันทำงาน
Checksum คือค่าที่คำนวณจากข้อมูลเพื่อใช้ตรวจความตรงกัน บทความนี้ใช้ SHA-256 ซึ่งเป็นฟังก์ชันแฮชทางเดียว รับข้อมูลแล้วให้ผลขนาดคงที่ ตามคำอธิบายใน MessageDigest เราเปรียบเทียบผลนี้กับค่าอ้างอิง แทนการตัดสินจากชื่อไฟล์หรือขนาดอย่างเดียว
- เพิ่มด่านตรวจในงานดาวน์โหลดอัตโนมัติ ก่อนส่งไฟล์ไปติดตั้งหรือแตกแพ็กเกจ
- เมื่อไฟล์ไม่ตรงกัน ทีมมีเหตุให้หยุดตรวจสอบ ไม่ปล่อยผ่านเพียงเพราะดาวน์โหลดสำเร็จ
- ใช้ไลบรารีมาตรฐานของ Java ได้ ไม่ต้องเพิ่มแพ็กเกจ
แต่ checksum ไม่ใช่ใบรับรองความปลอดภัย ถ้าผู้โจมตีเปลี่ยนทั้งไฟล์และค่า checksum บนหน้าเว็บเดียวกันได้ การเทียบก็ยังผ่าน ลายเซ็นดิจิทัลเพิ่มการตรวจว่าข้อมูลสอดคล้องกับกุญแจของผู้ลงนาม โดยเราต้องเชื่อถือกุญแจนั้นก่อน ส่วน provenance คือข้อมูลที่มาหรือกระบวนการสร้างไฟล์ หากผู้ผลิตมีลายเซ็นหรือหลักฐานที่มาที่ตรวจสอบได้ ให้ใช้ร่วมด้วย แฮชตรงไม่ได้แปลว่าไฟล์ปลอดภัย
ทำงานยังไง
นึกถึงรับพัสดุเข้าร้านแล้วตรวจรายการสินค้ากับใบส่งของ ถ้าของกับใบส่งของถูกสลับมาพร้อมกัน การตรวจรายการอย่างเดียวก็ช่วยไม่ได้ ค่าอ้างอิงของแฮชจึงต้องมาจากแหล่งที่เราไว้ใจ ไม่ใช่คำนวณจากไฟล์ที่เพิ่งรับมาแล้วประกาศว่าผ่านเองครับ
เริ่มด้วย MessageDigest.getInstance("SHA-256") ตามชื่อใน Java Security Standard Algorithm Names จากนั้นอ่านไบต์ของไฟล์ทีละก้อน ส่งเฉพาะส่วนที่อ่านได้เข้า update(buffer, 0, n) และเรียก digest() หลังอ่านจบ วิธีนี้เก็บเพียงบัฟเฟอร์อ่านไฟล์ ไม่ต้องโหลดไฟล์ทั้งหมดเข้าอาร์เรย์เดียว
ผล SHA-256 มีขนาด 32 ไบต์ เมื่อนำมาแสดงเป็นเลขฐานสิบหก หรือ hex จะยาว 64 ตัวอักษร HexFormat.of().formatHex() ให้ตัวพิมพ์เล็กและไม่มีตัวคั่น ส่วน parseHex() แปลงข้อความกลับเป็นไบต์ โดยรับตัวเลข hex ทั้งตัวพิมพ์เล็กและใหญ่ คลาสนี้มีตั้งแต่ Java 17 ตาม เอกสาร HexFormat
เราเทียบไบต์ด้วย MessageDigest.isEqual() เอกสารระบุว่าต้องยาวเท่ากันและแต่ละตำแหน่งตรงกันจึงเป็นจริง ส่วน implementation note ระบุว่าเวลาคำนวณขึ้นกับความยาวอาร์เรย์ตัวแรก ไม่ขึ้นกับเนื้อหาของทั้งสองอาร์เรย์ จึงไม่ควรขยายความว่าเวลาของกระบวนการตรวจไฟล์ทั้งหมดคงที่ เพราะยังมีการอ่านไฟล์และแปลงข้อความด้วย ดูรายละเอียดใน MessageDigest.isEqual
โค้ดตัวอย่างพร้อมเทสต์
บันทึกบล็อกนี้เป็น Main.java แล้วใช้ javac -encoding UTF-8 Main.java ตามด้วย java -ea Main โดย -ea เปิดการตรวจ assert ตัวอย่างสร้างไฟล์ชั่วคราวจากรายการรับเงินภาษาไทย อ่านด้วยบัฟเฟอร์เล็กเพื่อให้ผ่านหลายรอบ แล้วลบไฟล์ใน finally
ค่า expected ในเทสต์คำนวณจากไบต์ต้นฉบับทั้งชุดด้วย digest(byte[]) อีกเส้นทางหนึ่ง เทียบกับการอ่านเป็นก้อน และมีเทสต์ข้อมูลว่างที่รู้คำตอบไว้ตรวจเพิ่ม ในงานจริงต้องแทน expected ด้วยค่าอ้างอิงของผู้ผลิตที่ตรวจสอบแหล่งมาแล้ว ไม่ใช่สร้างจากไฟล์ดาวน์โหลดเอง
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
public class Main {
private static final HexFormat HEX = HexFormat.of();
static byte[] sha256(Path file) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream in = Files.newInputStream(file)) {
byte[] buffer = new byte[16];
int n;
while ((n = in.read(buffer)) != -1) {
md.update(buffer, 0, n);
}
}
return md.digest();
}
static boolean verify(Path file, String expectedHex) throws Exception {
byte[] expected = HEX.parseHex(expectedHex);
if (expected.length != 32) {
throw new IllegalArgumentException("SHA-256 needs 32 bytes");
}
byte[] actual = sha256(file);
return MessageDigest.isEqual(actual, expected);
}
public static void main(String[] args) throws Exception {
boolean assertionsEnabled = false;
assert assertionsEnabled = true;
if (!assertionsEnabled) {
throw new IllegalStateException("Run with java -ea Main");
}
byte[] original = (
"วันที่ 2026-10-21\n"
+ "สมชาย,ค่าบริการ,1250.00 บาท\n"
+ "มาลี,ค่าจัดส่ง,80.00 บาท\n"
).getBytes(StandardCharsets.UTF_8);
// Test oracle: whole byte array, separate from file streaming.
byte[] expected = MessageDigest.getInstance("SHA-256")
.digest(original);
String expectedHex = HEX.formatHex(expected);
assert expected.length == 32;
assert expectedHex.length() == 64;
Path file = Files.createTempFile(Path.of("."), "receipt-", ".txt");
try {
Files.write(file, original);
assert MessageDigest.isEqual(sha256(file), expected);
assert verify(file, expectedHex);
String upper = HEX.withUpperCase().formatHex(expected);
assert verify(file, upper);
boolean rejected = false;
try {
verify(file, "00");
} catch (IllegalArgumentException e) {
rejected = true;
}
assert rejected : "Reject wrong digest length";
rejected = false;
try {
verify(file, "zz".repeat(32));
} catch (IllegalArgumentException e) {
rejected = true;
}
assert rejected : "Reject non-hex characters";
byte[] changed = original.clone();
changed[changed.length - 1] ^= 1;
Files.write(file, changed);
assert !MessageDigest.isEqual(sha256(file), expected);
assert !verify(file, expectedHex) : "Reject modified file";
Files.write(file, new byte[0]);
String emptyHash = HEX.formatHex(sha256(file));
assert emptyHash.equals(
"e3b0c44298fc1c149afbf4c8996fb924"
+ "27ae41e4649b934ca495991b7852b855"
) : "SHA-256 empty input known-answer test";
System.out.println("SHA-256: " + expectedHex);
System.out.println("OK: all checksum tests passed");
} finally {
Files.deleteIfExists(file);
}
}
}
ผมรันด้วย OpenJDK Temurin 21.0.12.1+1 โดยเปิด -ea แล้ว เทสต์ทั้งหมดผ่านและโปรแกรมพิมพ์ OK: all checksum tests passed ครับ
เทสต์ตรวจทั้งค่าที่ถูกต้อง ข้อมูลผิดรูปแบบ ไฟล์เปลี่ยนหนึ่งไบต์ และไฟล์ว่าง แฮชในกรณีที่แก้ไฟล์นี้ต่างกันจริง แต่ไม่ได้หมายความว่าแฮชไม่มีวันซ้ำ สำหรับไฟล์จริงให้เรียก verify() ด้วย path และค่าอ้างอิง แล้วหยุดงานเมื่อได้ false หรือเกิดข้อผิดพลาด อย่าใช้ assert เป็นด่านป้องกันในระบบจริง เพราะการรันโดยไม่เปิด -ea จะข้ามการตรวจนั้น
จุดที่คนพลาดบ่อย
- ส่งทั้งบัฟเฟอร์เข้าแฮชทุกครั้ง: รอบสุดท้ายอาจอ่านได้ไม่เต็ม ทำให้ไบต์ค้างจากรอบก่อนปนเข้าไป แก้โดยใช้
update(buffer, 0, n)ตามจำนวนที่อ่านได้จริง - อ่านเป็นข้อความแล้วเขียนกลับก่อนตรวจ: การแปลง encoding หรือรูปแบบขึ้นบรรทัดใหม่ทำให้ไบต์เปลี่ยน แก้โดยอ่านไบต์จากไฟล์ตรง ๆ ส่วน UTF-8 ในตัวอย่างใช้กำหนดข้อมูลทดสอบเท่านั้น
- เทียบ hex ด้วยรูปแบบที่ไม่ตรงกัน: ตัวพิมพ์ใหญ่กับเล็กอาจแทนค่าเดียวกัน แก้โดย
parseHex()แล้วเทียบ digest และตรวจว่าผลแปลงยาว 32 ไบต์ หากคัดลอกจากไฟล์ checksum ให้แยกชื่อไฟล์และช่องว่างออกตามรูปแบบนั้นก่อน - เรียก
digest()ระหว่างอ่านแล้วหวังให้สะสมต่อ: เมธอดนี้จบการคำนวณและรีเซ็ตสถานะตามเอกสาร แก้โดยเรียกครั้งเดียวหลังอ่านครบ หรือสร้างอินสแตนซ์ใหม่เมื่อตรวจอีกไฟล์ - เก็บแฮชถูก แต่เชื่อแหล่งผิด: ค่า checksum ที่มากับหน้าซึ่งถูกแก้ไขอาจถูกสลับพร้อมไฟล์ แก้โดยตรวจแหล่งอ้างอิง และใช้ลายเซ็นหรือหลักฐานที่มาของผู้ผลิตเมื่อมีให้ตรวจ
ใช้ตรงไหนในงานจริง
- ขั้นตอน CI/CD หรืองานสร้างและปล่อยระบบอัตโนมัติ ตรวจแพ็กเกจก่อนนำไปประกอบโปรแกรม
- เครื่องมือของฝ่าย IT ตรวจชุดติดตั้งก่อนกระจายไปเครื่องพนักงาน เก็บค่าอ้างอิงและเวอร์ชันไว้คู่กันเพื่อย้อนตรวจได้
- งานย้ายไฟล์ระหว่างคลังภายใน ตรวจความตรงกันก่อนและหลังส่ง
- ระบบรับเอกสาร ตรวจว่าไบต์ยังตรงกับชุดที่รับไว้ครั้งแรก แต่แฮชไม่ยืนยันตัวผู้ส่ง
เทียบกับทางเลือกอื่น
| วิธี | ดีตรงไหน | ระวังอะไร |
|---|---|---|
| SHA-256 แบบอ่านเป็นก้อน | ใช้บัฟเฟอร์คงขนาด เหมาะกับไฟล์ที่ไม่อยากเก็บทั้งหมดในหน่วยความจำ | ต้องป้อนเฉพาะไบต์ที่อ่านได้ และอ่านให้ครบ |
digest(byte[]) จากข้อมูลทั้งชุด | โค้ดสั้น เหมาะกับข้อมูลที่มีอยู่ในอาร์เรย์แล้ว | ต้องมีไบต์ทั้งหมดในหน่วยความจำ เหมาะเป็นอีกเส้นทางในเทสต์นี้ |
| ตรวจชื่อและขนาดไฟล์ | ใช้คัดกรองความผิดพลาดเบื้องต้นได้ง่าย | ข้อมูลที่ต่างกันอาจมีชื่อและขนาดเท่ากัน |
| ลายเซ็นและหลักฐานที่มา | เพิ่มข้อมูลเรื่องผู้ลงนามหรือกระบวนการสร้างให้ตรวจสอบ | ต้องตรวจความน่าเชื่อถือของกุญแจและหลักฐานด้วย |
Recap
- ใช้ชื่ออัลกอริทึม
SHA-256กับMessageDigest.getInstance() - อ่านไฟล์เป็นไบต์และส่งจำนวนที่อ่านได้เข้า
update() - เรียก
digest()หลังอ่านครบ แล้วแสดง hex ด้วยHexFormat - แปลงค่าอ้างอิงด้วย
parseHex()และตรวจความยาว 32 ไบต์ - ใช้
MessageDigest.isEqual()เทียบ digest และหยุดงานเมื่อไม่ผ่าน - เปิด
-eaเมื่อรันเทสต์ รวมกรณีไฟล์เปลี่ยนและข้อมูลว่าง - ตรวจแหล่ง checksum และใช้ลายเซ็นหรือหลักฐานที่มาเมื่อผู้ผลิตมีให้