นากการ์ตูนสีเขียวใส่เอี๊ยมวางกล่องพัสดุข้างเครื่องพิมพ์ลายนิ้วมือ แล้วเทียบการ์ดลายนิ้วมือสองใบที่ตรงกันพร้อมเครื่องหมายถูกสีเขียว ส่วนการ์ดที่ไม่ตรงถูกแยกไว้พร้อมเครื่องหมายสีอำพัน

เช็ก SHA-256 ของไฟล์ดาวน์โหลดด้วย Java ก่อนเอาเข้าระบบ

น้องกายEngineering

ไฟล์ติดตั้งดาวน์โหลดเสร็จและชื่อเวอร์ชันถูกต้อง ยังไม่พอให้เอาเข้าระบบบริษัททันทีครับ ข่าวไฟล์ปล่อยตัวถูกสลับช่วงนี้ทำให้ขั้นตอนตรวจไฟล์ก่อนใช้งานมีความหมายมากขึ้น เราจะใช้ 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 และใช้ลายเซ็นหรือหลักฐานที่มาเมื่อผู้ผลิตมีให้