ของที่ทำเสร็จแล้ว ทำงานยังไง
อ่านให้เห็นภาพว่าลายเซ็นและการเข้ารหัส 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_HEADERSIG_TIMESTAMP_INVALIDSIG_KEY_NOT_FOUNDSIG_INVALIDSIG_MALFORMEDSIG_BODY_UNREADABLESIG_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 จุด
- service ที่อยากได้ขาออกอย่างเดียว ยังถูกบังคับให้ถือแม่กุญแจของขาเข้า — เพราะสวิตช์ตัวเดียวเปิดทั้ง 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 | ต้อง |
| เข้ารหัสขาออก | คนกลางอ่านค่าอ่อนไหวที่ระบบตอบกลับ | ไม่มีเลย ใช้แล้วทิ้งทุกครั้ง | ไม่ต้อง |