งูสีเขียวต้นฉบับวางริบบิ้นปิดผนึกยาวเท่ากันบนตาชั่งสองแขนใต้โคมเดียวกัน

Python secrets.compare_digest: เทียบลายเซ็นโดยลด timing leak

พี่โชว์Engineering

การเทียบลายเซ็นด้วย expected == received อาจหยุดเมื่อพบ byte แรกที่ต่าง เวลาใช้จึงสัมพันธ์กับตำแหน่งที่เดาถูก แม้ความต่างจะเล็ก ผู้โจมตีที่วัดซ้ำจำนวนมากอาจใช้เป็น timing side channel secrets.compare_digest ถูกออกแบบให้ลดการรั่วแบบ short-circuit นี้เมื่อเทียบค่าชนิดและความยาวที่เหมาะสมครับ

WHY: ตรวจตราประทับให้ครบทั้งเส้น

ให้นึกถึงเจ้าหน้าที่เทียบริบบิ้นปิดผนึกสองเส้น ถ้าหยุดทันทีตรงจุดแรกที่ต่าง คนเฝ้าดูเวลาอาจเดาได้ว่าช่วงต้นตรงกันยาวแค่ไหน การตรวจด้วยวิธีที่เวลาขึ้นกับค่าด้านในน้อยลงช่วยลดสัญญาณนี้ แต่ไม่ได้ทำให้ระบบ “constant time” ทุกชั้น เพราะ decode, length, routing, logging และ response ยังรั่วข้อมูลได้

วิธีใช้ที่ถูกคือคำนวณ MAC จาก raw body เดิม ด้วย secret ที่ถูกต้อง decode signature เป็น bytes แล้วเทียบ bytes กับ bytes อย่า hash password ด้วย HMAC ตัวอย่างนี้; password ต้องใช้ password hashing KDF ที่ออกแบบมาช้าและมี salt

HOW: ตรวจ HMAC หลัง validate header

import hashlib
import hmac
import secrets
import unittest


def valid_signature(body: bytes, header_hex: str, secret: bytes) -> bool:
    try:
        received = bytes.fromhex(header_hex)
    except ValueError:
        return False

    expected = hmac.digest(secret, body, "sha256")
    if len(received) != len(expected):
        return False
    return secrets.compare_digest(received, expected)


class SignatureTests(unittest.TestCase):
    def test_accepts_exact_signature(self):
        body = b'{"event":"paid"}'
        key = b"test-only-secret"
        signature = hmac.digest(key, body, "sha256").hex()
        self.assertTrue(valid_signature(body, signature, key))

    def test_rejects_changed_body_and_bad_hex(self):
        key = b"test-only-secret"
        signature = hmac.digest(key, b"original", "sha256").hex()
        self.assertFalse(valid_signature(b"changed", signature, key))
        self.assertFalse(valid_signature(b"original", "not-hex", key))

    def test_rejects_wrong_length(self):
        self.assertFalse(valid_signature(b"body", "00", b"test-only-secret"))


if __name__ == "__main__":
    unittest.main()

ตัวอย่างใช้ hmac.digest เพื่อคำนวณ HMAC-SHA-256 แล้วใช้ compare_digest เฉพาะขั้นเทียบ การเช็กความยาวก่อนช่วยให้ contract ชัด แต่ความยาวที่ต่างอาจทำให้เวลาแตกต่างได้ เอกสารเองเตือนว่าชนิดหรือความยาวต่างกันอาจเปิดเผยข้อมูลบางอย่าง ดังนั้น protocol ควรกำหนด algorithm และขนาด signature ตายตัวอยู่แล้ว

ชนิดต้องตรงกัน

compare_digest รองรับ bytes กับ bytes หรือ ASCII str กับ str อย่าเทียบ hex string ฝั่งหนึ่งกับ raw bytes อีกฝั่ง และอย่า normalize case/space แบบเดาสุ่ม เพราะลายเซ็นคือ protocol ไม่ใช่ข้อความมนุษย์ Decode รูปแบบที่กำหนด เช่น hex หรือ Base64 อย่างเข้มงวด แล้วเทียบผลลัพธ์ชนิดเดียวกัน

ต้องคำนวณจาก raw request bytes ก่อน JSON parse/re-serialize เพราะ whitespace, ลำดับ key หรือ Unicode escaping อาจเปลี่ยน byte ทั้งที่ object ดูเหมือนเดิม จำกัด body size ก่อนอ่านทั้งหมด และเก็บ secret ใน secret manager ไม่ฝังใน source หรือ log

ยังต้องกัน replay

ลายเซ็นที่ถูกต้องบอกว่า payload มาจากผู้ถือ secret และไม่ถูกแก้ระหว่างทาง แต่มันอาจเป็น request เก่าที่ผู้โจมตีส่งซ้ำ ต้องตรวจ timestamp window, nonce หรือ event id ตาม protocol และบันทึก idempotency key แบบ atomic การตอบ error ควรสม่ำเสมอพอสมควร ไม่เผยว่า fail ที่ format, MAC หรือ account ใด

Key rotation ต้องรองรับ key ปัจจุบันกับ key เก่าช่วงสั้นโดยไม่ลอง key ไม่จำกัดจำนวน ตั้ง key id ที่ไม่เป็น secret, จำกัดหน้าต่าง และ monitor อัตรา verify fail หากมีหลาย tenant ต้องเลือก key จากข้อมูลที่ authenticated หรือ mapping ที่ควบคุมได้ ไม่เชื่อ tenant id ที่เอาไปอ่าน path ตามใจผู้ส่ง

Recap

  • คำนวณ HMAC จาก raw body ด้วย algorithm ที่ protocol กำหนด
  • decode signature เป็นชนิดเดียวกันก่อน compare_digest
  • จำกัด body, header และ signature length
  • แยก MAC verification จาก replay/idempotency protection
  • เก็บ secret นอก source และออกแบบ key rotation
  • อย่าเรียกวิธีนี้ว่า “ปลอดภัยแน่นอน”; timing รอบข้างยังสำคัญ

เริ่มจาก test เปลี่ยน body หนึ่ง byte, signature ผิดรูป, ความยาวผิด และ event ส่งซ้ำครับ เพราะ security boundary ที่ดีต้องปฏิเสธข้อมูลผิดโดยไม่สร้าง side effect ก่อนตรวจครบ