Private Docs

ของที่ทำเสร็จแล้ว ทำงานยังไง

อ่านให้เห็นภาพว่าลายเซ็นและการเข้ารหัส field ทำงานยังไงจริง ๆ หลัง implement เสร็จ — เทียบกับของใช้ในชีวิตจริง + ภาพลำดับทีละขั้น · ไม่ใช่คู่มือติดตั้ง ไม่มีโค้ดให้ copy

อัปเดต: 2026-09-01

หน้านี้ตอบคำถามเดียว: ของที่ทำเสร็จแล้ว มันทำงานยังไง — เขียนจากโค้ดที่ merge แล้วจริง ไม่ใช่จากแผน

ไม่มีขั้นตอนติดตั้ง ไม่มีโค้ดให้ copy (อยู่หน้าติดตั้ง) · ที่นี่มีแค่ ของเทียบให้เห็นภาพ + ภาพลำดับว่าใครคุยกับใคร

ตอนนี้มี 2 กลไกที่ทำเสร็จและพิสูจน์แล้วบน service จริง


1. Body signature — ลายเซ็นกำกับทุกคำขอ

เทียบกับของจริง: เช็คที่เซ็นสั่งจ่าย

เวลาเราเขียนเช็ค เราไม่ได้เซ็นลอย ๆ — เราเซ็นบนใบที่มีตัวเลขเขียนอยู่แล้ว ถ้ามีคนไปแก้ตัวเลขทีหลัง ลายเซ็นนั้นใช้ไม่ได้ทันที เพราะมันผูกกับเนื้อความทั้งใบ

ระบบนี้ทำแบบเดียวกันกับทุก request:

เช็คระบบเรา
ลายเซ็นผูกกับตัวเลขบนใบลายเซ็นผูกกับ method + path + query + เนื้อ body ทั้งก้อน — แก้อะไรสักตัวลายเซ็นก็เพี้ยน
ตัวอย่างลายเซ็นที่ธนาคารเก็บไว้public key ของผู้ใช้ ที่เก็บไว้ใน Redis ตอนลงทะเบียนครั้งแรก
ลายเซ็นจริงอยู่ในมือเจ้าของบัญชีprivate key อยู่ในเบราว์เซอร์ผู้ใช้ ออกมาไม่ได้ (non-extractable — โค้ด JS เองก็อ่านไม่ได้)
เช็คลงวันที่ไว้ ใบเก่าเกินไปไม่รับทุกคำขอมี timestamp ห่างจากเวลาจริงเกิน 5 นาที = ไม่รับ
ธนาคารจดเลขที่เช็คกันเบิกซ้ำจดลายนิ้วมือของคำขอไว้ ยิงซ้ำใบเดิม = ปฏิเสธ

จุดสำคัญ: ไม่มีความลับฝั่ง server เลย — server เก็บแต่ “ตัวอย่างลายเซ็น” ซึ่งเป็นของสาธารณะ หลุดไปก็ปลอมลายเซ็นไม่ได้ · กลไกนี้ไม่ต้องใช้ Key Vault

ลงทะเบียนครั้งแรก — ครั้งเดียวต่อเครื่อง

sequenceDiagram
    autonumber
    participant B as เบราว์เซอร์
    participant S as Service
    participant R as Redis

    B->>B: สร้างกุญแจคู่ ECDSA P-256 ครั้งแรกที่เปิดเว็บ
    Note over B: ครึ่งลับถูกตั้งให้เอาออกจากเครื่องไม่ได้ตั้งแต่วินาทีที่สร้าง
    B->>S: ส่งเฉพาะครึ่งเปิดเผยไปลงทะเบียน
    S->>R: จองช่องของผู้ใช้คนนี้ ถ้ายังว่าง
    alt ช่องว่าง
        R-->>S: จองสำเร็จ
        S-->>B: ลงทะเบียนแล้ว
    else มีกุญแจเดิมอยู่ และเป็นดอกเดียวกัน
        S-->>B: ถือว่าสำเร็จเงียบ ๆ เผื่อผู้ใช้ refresh หน้า
    else มีกุญแจเดิมอยู่ แต่คนละดอก
        S-->>B: ปฏิเสธ 409
    end

ทำไมกรณีสุดท้ายถึงปฏิเสธ: ช่องของผู้ใช้คนหนึ่งมีกุญแจได้ดอกเดียว ใครมาทีหลังเปลี่ยนไม่ได้ — ถ้ายอมให้เปลี่ยน คนที่ขโมย session ไปได้ก็แค่ลงทะเบียนกุญแจตัวเองทับ แล้วเซ็นอะไรก็ได้ในนามเจ้าของ

ผลข้างเคียงที่เจอบ่อย: ล้างข้อมูลเบราว์เซอร์ หรือเปิด profile ใหม่ = กุญแจใหม่ = โดน 409 ต้องลบของเดิมใน Redis ก่อน

ทุกคำขอหลังจากนั้น

sequenceDiagram
    autonumber
    participant B as เบราว์เซอร์
    participant S as Service
    participant R as Redis
    participant H as Handler

    B->>B: ประกอบข้อความสรุปคำขอ แล้วเซ็นด้วยกุญแจในเครื่อง
    B->>S: ส่งคำขอ พร้อมลายเซ็นและเวลาแนบไปในหัวจดหมาย
    S->>S: เวลาห่างเกิน 5 นาทีไหม
    S->>R: ขอตัวอย่างลายเซ็นของผู้ใช้คนนี้
    R-->>S: ครึ่งเปิดเผยที่ลงทะเบียนไว้
    S->>S: ประกอบข้อความสรุปคำขอเองอีกรอบ แล้วเทียบลายเซ็น
    S->>R: จดลายนิ้วมือของคำขอไว้ ถ้ายังไม่เคยเห็น
    alt เคยเห็นแล้ว
        S-->>B: ปฏิเสธ ยิงซ้ำ
    else ยังไม่เคยเห็น
        S->>H: ปล่อยเข้า handler
    end

“ข้อความสรุปคำขอ” คืออะไร — ทั้ง 2 ฝั่งประกอบเองจากของเดียวกัน 6 บรรทัด ไม่ได้ส่งข้ามมา:

v1
{เวลาเป็นวินาที}
{METHOD ตัวใหญ่}
{path}
{query ที่เรียงแล้ว}
{ลายนิ้วมือของ body}

ถ้าสองฝั่งประกอบได้ไม่ตรงกันแม้แต่ตัวเดียว ลายเซ็นก็ไม่ผ่าน — นี่คือสาเหตุของปัญหาแทบทุกเคส ไม่ใช่ตัวเลขคริปโตผิด

รายละเอียดที่ไม่ต้องอ่านก็ได้ — ค่าจริงที่ใช้อยู่
  • ลายเซ็น ECDSA P-256 + SHA-256 · หัวจดหมาย X-Signature และ X-Timestamp (ตั้งชื่ออื่นได้)
  • timestamp เป็น unix วินาที · ยอมรับห่างได้ ±300 วินาที
  • “ลายนิ้วมือของ body” = base64(sha256(body)) · body ว่างก็ยังต้องมีค่านี้
  • “ลายนิ้วมือของคำขอ” ที่ใช้กันยิงซ้ำ = ค่าที่คำนวณจากข้อความสรุป ไม่ใช่จากตัวลายเซ็น — เพราะลายเซ็น ECDSA ใบเดิมเขียนได้หลายแบบ ถ้าจดตัวลายเซ็นจะเลี่ยงการตรวจได้ง่าย ๆ
  • ช่องจดกันยิงซ้ำอยู่ได้ 600 วินาที (2 เท่าของหน้าต่างเวลา) แล้วหายเอง
  • มี 2 โหมด: LogOnly (ค่าเริ่มต้น — ตรวจแล้วจดลง log ปล่อยผ่าน) และ Enforce (ปฏิเสธจริง)
  • เหตุผลที่ปฏิเสธมีรหัสกำกับทุกแบบ: SIG_MISSING_HEADER SIG_TIMESTAMP_INVALID SIG_KEY_NOT_FOUND SIG_INVALID SIG_MALFORMED SIG_BODY_UNREADABLE SIG_REPLAY และ 2 ตัวสำหรับตอน Redis ล่ม
  • กับดักที่เจอจริง: path ที่เซ็นต้องเป็น path ที่ service เห็น ไม่ใช่ path ที่เบราว์เซอร์ยิง เพราะ gateway ตัดคำนำหน้าออกกลางทาง

2. Encryption Field — เข้ารหัสเฉพาะช่องที่อ่อนไหว

ไม่ได้เข้ารหัสทั้งคำขอ — เข้ารหัสเฉพาะช่องที่ระบุไว้ เช่น เลขบัตรประชาชน เบอร์โทร ที่อยู่ ส่วนช่องอื่นยังอ่านออกตามปกติ (จำเป็น เพราะ log กับ trace ต้องยังตามงานได้)

handler ไม่รู้เรื่องทั้ง 2 ทาง — ตอนรับก็เห็นค่าปกติ ตอนตอบก็ใส่ค่าปกติ มีตัวกลางแกะและห่อให้ก่อนและหลัง

เทียบกับของจริง: กล่องพัสดุที่มีซองกุญแจติดไปด้วย

ทุกครั้งที่ส่งของ เราสุ่มกุญแจใหม่หนึ่งดอกมาล็อกกล่อง แล้วส่งกุญแจดอกนั้นไปกับกล่องด้วย เพราะปลายทางต้องใช้เปิด

ปัญหาคือ ถ้ากุญแจห้อยอยู่ข้างกล่อง ใครหยิบก็เปิดได้ ⇒ ต้องเอากุญแจใส่ซองแล้วล็อกซองไว้อีกที ให้เฉพาะปลายทางเปิดซองได้

นี่คือหัวใจ: กุญแจสุ่มมีจริงและสุ่มใหม่ทุกครั้ง — แต่มันเดินทางไปกับของ เลยไม่ได้แทนที่แม่กุญแจ มันแค่ทำให้แม่กุญแจทำงานน้อยลง (ล็อกแค่ซองเล็ก ๆ ไม่ต้องล็อกทั้งกล่อง) และถ้ากุญแจสุ่มดอกเดียวหลุด ก็เสียแค่กล่องใบนั้น

คำถามที่เหลืออยู่ข้อเดียวคือ ใครล็อกซอง — และคำตอบต่างกันตามทิศทาง เพราะ “ฝั่งไหนเป็นคนเปิด ฝั่งนั้นต้องมีแม่กุญแจถาวร”

ทิศทางคนเปิดคือใครแม่กุญแจถาวรเป็นของใคร เก็บที่ไหนต้องใช้ Key Vault
ขาเข้า เบราว์เซอร์ส่งมาServiceของ service เอง — ต้องอยู่รอดข้าม request ข้าม pod ข้าม restartต้อง
ขาออก service ตอบกลับเบราว์เซอร์ของผู้ใช้ อยู่ในเครื่องผู้ใช้ ไม่เคยออกมาไม่ต้อง

ขาออกไม่ต้องใช้ Key Vault ไม่ใช่เพราะเลือกวิธีเก่งกว่า แต่เพราะเราเป็นฝ่ายส่ง ไม่ใช่ฝ่ายเปิด — แม่กุญแจถาวรมีอยู่จริง แต่มันเป็นของผู้ใช้ ไม่ใช่ของเรา เราเลยไม่มีอะไรต้องเก็บ

ขาเข้า — เบราว์เซอร์เข้ารหัสมา service ถอด

sequenceDiagram
    autonumber
    participant B as เบราว์เซอร์
    participant S as Service
    participant KV as Key Vault
    participant H as Handler

    S->>KV: ตอนแอปสตาร์ท ไปเอาแม่กุญแจของตัวเองมา
    Note over S: ไม่มีกุญแจ แอปไม่ยอมสตาร์ท ตั้งใจให้พังตอนนี้ ไม่ใช่ตอนผู้ใช้ยิงจริง
    B->>S: ขอครึ่งเปิดเผยของแม่กุญแจ service
    S-->>B: ครึ่งเปิดเผย พร้อมหมายเลขรุ่นของกุญแจ
    B->>B: สุ่มกุญแจใหม่ 1 ดอก ล็อกเฉพาะช่องที่อ่อนไหว
    B->>B: เอาครึ่งเปิดเผยของ service ล็อกซองที่ใส่กุญแจสุ่มไว้
    B->>S: ส่งคำขอ ช่องนั้นกลายเป็นก้อนอ่านไม่ออก ช่องอื่นปกติ
    S->>S: เปิดซองด้วยแม่กุญแจของตัวเอง ได้กุญแจสุ่มมา
    S->>S: ใช้กุญแจสุ่มเปิดค่าจริง แล้วใส่กลับเข้าที่เดิม
    S->>H: handler เห็นค่าปกติ ไม่รู้เลยว่าเมื่อกี้ถูกเข้ารหัสมา

ทำไมขาเข้าถึงเลี่ยง Key Vault ไม่ได้ — คนส่งมีเป็นพันเครื่อง คนเปิดมีตัวเดียวคือ service · request จะมาถึงตอนไหนก็ได้ ตอนตี 3 ก็ต้องเปิดได้ ⇒ แม่กุญแจต้องมีอยู่ตลอดเวลา ⇒ ต้องเก็บในที่ที่ปลอดภัย · เปลี่ยนไปใช้สูตรอื่นก็ยังต้องเก็บอยู่ดี เพราะเงื่อนไขคือ “service เป็นฝ่ายเปิด” ไม่ใช่เรื่องสูตร

ขาออก — service เข้ารหัสตอบกลับ เบราว์เซอร์ถอด

ก่อนใช้ได้ เบราว์เซอร์ต้องลงทะเบียนกุญแจดอกที่สองไว้ก่อน (คนละดอกกับที่ใช้เซ็น เพราะกุญแจที่สร้างมาเพื่อเซ็น เบราว์เซอร์ไม่ยอมให้เอาไปทำอย่างอื่น)

sequenceDiagram
    autonumber
    participant H as Handler
    participant S as Service
    participant R as Redis
    participant B as เบราว์เซอร์

    H->>S: คืนค่าปกติ ไม่ได้เข้ารหัสอะไร
    S->>R: ขอกุญแจรับของที่ผู้ใช้คนนี้ลงทะเบียนไว้
    alt ไม่เคยลงทะเบียน
        R-->>S: ไม่มี
        S-->>B: ส่งค่าปกติกลับไป ของเดิมยังใช้ได้ ไม่พัง
    else มี
        R-->>S: ครึ่งเปิดเผยของผู้ใช้
        S->>S: สร้างกุญแจใช้แล้วทิ้งขึ้นมาใหม่หนึ่งคู่
        S->>S: เอาครึ่งลับของตัวเอง ผสมกับครึ่งเปิดเผยของผู้ใช้ ได้แม่กุญแจ
        S->>S: สุ่มกุญแจล็อกค่าจริง แล้วใช้แม่กุญแจนั้นล็อกซองไว้
        S->>S: ทิ้งครึ่งลับของตัวเองทันที
        S-->>B: ส่งกลับ พร้อมแนบครึ่งเปิดเผยของกุญแจใช้แล้วทิ้ง
        B->>B: เอาครึ่งลับถาวรของตัวเอง ผสมกับครึ่งที่แนบมา
        Note over B: ได้แม่กุญแจดอกเดียวกันเป๊ะ ทั้งที่ไม่มีใครส่งแม่กุญแจข้ามสายเลย
        B->>B: เปิดซอง เอากุญแจสุ่มมาเปิดค่าจริง
    end

จุดที่คนเข้าใจผิดบ่อยที่สุด

“ในเมื่อกุญแจสุ่มหมด คนดักกลางก็ถอดได้สิ” — ไม่ได้ครับ เพราะแม่กุญแจไม่เคยเดินทาง มันถูกคำนวณขึ้นทั้งสองฝั่ง

เทียบง่าย ๆ เหมือนผสมสี: แต่ละฝ่ายมีสีลับของตัวเอง เอามาผสมกับสีที่อีกฝ่ายเปิดเผยไว้ แล้วได้สีผลลัพธ์เหมือนกันเป๊ะทั้งคู่ — คนกลางเห็นแต่สีเปิดเผยของทั้งสองฝ่าย ผสมยังไงก็ไม่ได้สีนั้น เพราะขาดสีลับ

ของลับสองอันที่คนกลางไม่มีทางได้:

  • ครึ่งลับของ service — ทิ้งไปแล้วตั้งแต่ส่งเสร็จ ต่อให้บุกเข้า server ทีหลังก็ไม่มีให้ขโมย และเปิด response เก่าที่ดักไว้ไม่ได้
  • ครึ่งลับของผู้ใช้ — ถูกสร้างมาแบบเอาออกจากเครื่องไม่ได้ตั้งแต่แรก
รายละเอียดที่ไม่ต้องอ่านก็ได้ — สูตรจริงที่ใช้
  • ทั้ง 2 ทิศใช้ AES-256-GCM ล็อกค่าจริง กุญแจสุ่ม 32 bytes และ IV สุ่ม 12 bytes ใหม่ทุกใบ · ซองที่ห่อคือรูปแบบมาตรฐาน JWE Compact 5 ส่วนคั่นด้วยจุด
  • ขาเข้า ล็อกซองด้วย RSA-OAEP-256 — แม่กุญแจของ service ประกาศครึ่งเปิดเผยออกทาง JWKS พร้อมหมายเลขรุ่น เอาไว้หมุนกุญแจโดยของเก่ายังอ่านได้
  • ขาออก ล็อกซองด้วย ECDH-ES+A256KW — ตกลงแม่กุญแจกับกุญแจ EC P-256 ของเบราว์เซอร์ · ครึ่งเปิดเผยของกุญแจใช้แล้วทิ้งเดินทางไปในหัวของซอง
  • ทำไมสองทิศคนละสูตร: สูตรถูกบังคับด้วยชนิดกุญแจของฝ่ายที่เปิด — ของ service เป็น RSA ของเบราว์เซอร์เป็น EC และสองชนิดนี้ใช้วิธีล็อกร่วมกันไม่ได้ · ที่เบราว์เซอร์ไม่ใช้ RSA ให้เหมือนกันเพราะสร้างกุญแจ RSA ในเบราว์เซอร์กินเวลาเป็นวินาที และไปขวางตอนผู้ใช้กำลังใช้งานพอดี
  • ค่าที่เข้ารหัสเดินทางเป็นรูป JSON ของมัน ไม่ใช่ค่าเปล่า — ปลายทางถอดแล้ว parse ต่อ จะได้ตัวหนังสือกลับมาเป็นตัวหนังสือ ตัวเลขกลับมาเป็นตัวเลข และช่องที่เป็น object ทั้งก้อนก็เข้ารหัสได้
  • ตอบเฉพาะ 2xx — ถ้า response เป็น error จะไม่ถูกแตะเลย เพราะ client ต้องอ่านได้ว่าพลาดเพราะอะไร
  • ป้ายที่ใช้บอกว่าช่องไหนเข้ารหัสเป็นป้ายเดียวกันทั้ง 2 ทิศ ตำแหน่งที่ติดเป็นตัวบอกทิศ — ติดบนคำขอแปลว่า “เข้ามาแบบเข้ารหัส ให้ถอด” ติดบนคำตอบแปลว่า “ออกไปแบบเข้ารหัส”
  • เป็นรายช่องเท่านั้น ไม่มีโหมดเข้ารหัสทั้งก้อน — ของเดิมเคยมีแล้วเลิกไป เพราะเข้ารหัสทั้งก้อนแปลว่าช่องที่ใช้ตามงานใน log ก็อ่านไม่ออกไปด้วย · เรื่องนี้ยังถกกันอยู่ว่าจะกลับไปทำทั้งก้อนไหมเข้ารหัสรายฟิลด์ หรือ เข้ารหัสทั้ง payload · หน้านี้เขียนตามของที่ทำเสร็จแล้ววันนี้
ข้อจำกัดที่ยังมีอยู่ตอนนี้ — กลไกกับการต่อสายไม่ตรงกัน 2 จุด
  1. service ที่อยากได้ขาออกอย่างเดียว ยังถูกบังคับให้ถือแม่กุญแจของขาเข้า — เพราะสวิตช์ตัวเดียวเปิดทั้ง 2 ทิศ และตัวเปิดตรวจว่าต้องมีแม่กุญแจก่อนถึงจะยอมสตาร์ท · ตัวขาออกเองไม่แตะแม่กุญแจนั้นเลยสักครั้ง · แยกสวิตช์ได้ถ้าต้องการ — ยังไม่ได้ตัดสิน
  2. ฝั่งเบราว์เซอร์มีโค้ดครบแล้ว แต่ยังไม่มีใครเรียกใช้ — ทั้งการลงทะเบียนกุญแจดอกที่สอง และการถอดค่าที่ได้กลับมา ยังไม่ถูกต่อเข้ากับแอปจริง ⇒ ถ้าเปิดขาออกวันนี้ ผู้ใช้จะเห็นก้อนอ่านไม่ออกบนหน้าจอ

ทั้งสองกลไกอยู่ตรงไหนของคำขอเดียวกัน

sequenceDiagram
    autonumber
    participant B as เบราว์เซอร์
    participant G as Gateway
    participant S as Service
    participant H as Handler

    B->>G: คำขอ พร้อมลายเซ็น และช่องที่อ่อนไหวถูกเข้ารหัสไว้แล้ว
    G->>S: แปลง session เป็น token แล้วส่งต่อ
    S->>S: ตรวจ token ว่าเป็นใคร
    S->>S: ตรวจลายเซ็น ว่าคำขอนี้เจ้าตัวส่งเองและไม่ถูกแก้กลางทาง
    S->>S: ถอดช่องที่เข้ารหัสมา
    S->>H: handler ทำงานกับค่าปกติ
    H->>S: คืนค่าปกติ
    S->>S: เข้ารหัสช่องที่ระบุไว้ ให้เฉพาะเครื่องที่ขอมาเปิดได้
    S-->>B: ตอบกลับ

ลำดับนี้สลับไม่ได้ — ต้องรู้ก่อนว่าเป็นใคร (token) ถึงจะไปหยิบตัวอย่างลายเซ็นของคนนั้นมาตรวจได้ และต้องตรวจลายเซ็นให้ผ่านก่อน ถึงจะยอมเสียเวลาถอดรหัสให้

สรุปสั้นที่สุด

ป้องกันอะไรความลับฝั่ง serverต้อง Key Vault
ลายเซ็นคนกลางแก้คำขอ · ปลอมเป็นคนอื่น · ยิงซ้ำไม่มีเลย เก็บแต่ของสาธารณะไม่ต้อง
เข้ารหัสขาเข้าคนกลางอ่านค่าอ่อนไหวที่ผู้ใช้กรอกแม่กุญแจของ serviceต้อง
เข้ารหัสขาออกคนกลางอ่านค่าอ่อนไหวที่ระบบตอบกลับไม่มีเลย ใช้แล้วทิ้งทุกครั้งไม่ต้อง