FE 1 — Body Signature ฝั่งหน้าบ้าน: เซ็นทุกคำขอจากเบราว์เซอร์
ฝั่งที่ต้องเซ็น — กุญแจถูกสร้างในเครื่องผู้ใช้แบบดึงออกมาไม่ได้ เก็บใน IndexedDB และ interceptor เซ็นให้อัตโนมัติ · ต่อสาย 3 บรรทัด แต่มีกับดัก 5 ข้อที่ทำให้ลายเซ็นไม่ตรงแบบหาสาเหตุยาก
อัปเดต: 2026-08-23
คู่กับ BE — Body Signature · ข้อความที่เซ็นต้องตรงกันทุกตัวอักษร ไม่งั้นได้
401 SIG_INVALIDทุกคำขอ
1. กุญแจอยู่ที่ไหน และทำไมถึงปลอดภัย
สร้างครั้งแรกที่เครื่องผู้ใช้ → เก็บ handle ไว้ใน IndexedDB → ส่งเฉพาะ "ครึ่งสาธารณะ" ขึ้น server
- กุญแจถูกสร้างด้วย
extractable: false⇒ แม้แต่โค้ดของเราเองก็ export กุญแจลับออกมาไม่ได้ สคริปต์แปลกปลอมที่หลุดเข้ามาในหน้าเว็บจึงขโมยกุญแจไปใช้ที่อื่นไม่ได้ - ครึ่งลับไม่เคยออกจากเครื่อง — ที่ส่งขึ้น server มีแค่
kty/crv/x/y - เปิดแท็บใหม่หรือ refresh แล้วใช้กุญแจเดิมต่อได้ (อ่านจาก IndexedDB)
2. ต่อสาย
// app.config.ts
provideHttpClient(withInterceptors([
csrfInterceptor, // ← ลำดับสำคัญ ดู §3 กับดักที่ 1
bodySignatureInterceptor,
])),
{ provide: BODY_SIGNATURE_SIGNER, useClass: WebCryptoBodySignatureSigner },
{
provide: BODY_SIGNATURE_CONFIG,
useValue: {
signedUrlPrefixes: ['/api/user-service/'], // เซ็นเฉพาะ URL ที่ขึ้นต้นด้วยสิ่งนี้
stripPathPrefixes: ['/gateway'], // prefix ที่ gateway ตัดทิ้งก่อนถึง service
registerUrl: '/api/user-service/v1/client-keys',
} satisfies BodySignatureConfig,
},
เท่านี้ทุกคำขอที่เข้าเงื่อนไขจะถูกเซ็นอัตโนมัติ — ไม่ต้องแก้โค้ดที่เรียก API ทีละจุด
3. กับดัก 5 ข้อ (ทั้งหมดเคยเกิดจริง)
1. ลำดับ interceptor — ถ้า interceptor ตัวอื่นทำ retry ด้วยการ clone จากคำขอ ต้นฉบับ แล้วเรียก next() มันจะ re-run เฉพาะ interceptor ที่อยู่ถัดไป ⇒ ถ้าตัวเซ็นอยู่ก่อน ตัว retry คำขอรอบใหม่จะพกลายเซ็นและเวลาของรอบเก่าไปด้วย แล้วตกหน้าต่างเวลา ให้วางตัวเซ็นไว้หลังสุดเสมอ
2. path ที่เซ็นต้องเป็น path ที่ service เห็น ไม่ใช่ path ที่ browser ยิง — ถ้ามี gateway ตัด prefix ออกก่อนส่งต่อ ต้องบอก stripPathPrefixes ให้ตัดเหมือนกัน ไม่งั้นสองฝั่งเซ็นคนละข้อความ
3. เวลาใหม่ทุกครั้งที่เซ็น — รวมถึงตอน retry การใช้ค่าเดิมซ้ำคือสาเหตุอันดับหนึ่งของ SIG_TIMESTAMP_INVALID
const timestamp = String(Math.floor(Date.now() / 1000)); // วินาที ไม่ใช่มิลลิวินาที
4. อย่าเซ็นคำขอที่ไปลงทะเบียนกุญแจเอง — ถ้าเซ็นด้วย จะวนไม่จบ: เซ็น → SIG_KEY_NOT_FOUND → ไปลงทะเบียน → คำขอลงทะเบียนก็ถูกเซ็น → วนซ้ำ
5. อย่า cache promise ที่ล้มเหลว — ถ้า IndexedDB สะดุดครั้งเดียว (แท็บอื่นล็อกอยู่ / โควตาเต็ม / โหมดส่วนตัว) แล้วเก็บ promise ที่ reject ไว้ ทุกคำขอหลังจากนั้นจะพังตามกันหมดจนกว่าจะ reload หน้า
this.keyPairPromise = this.loadOrCreateKeyPair().catch((error) => {
this.keyPairPromise = null; // ทิ้งทันทีเมื่อล้มเหลว
throw error;
});
4. การกู้คืนอัตโนมัติ — กู้เฉพาะกรณีที่กู้แล้วมีความหมาย
| เจอรหัสนี้ | ทำอะไร |
|---|---|
SIG_KEY_NOT_FOUND | ลงทะเบียนกุญแจใหม่ แล้วเซ็นส่งอีกครั้ง (กุญแจหมดอายุ 7 วัน เป็นเรื่องปกติ) |
SIG_KEY_STORE_UNAVAILABLE · SIG_REPLAY_STORE_UNAVAILABLE | ไม่กู้ — ที่เก็บฝั่ง server ล่มอยู่ การยิงซ้ำคือการซ้ำเติม |
SIG_INVALID · SIG_MALFORMED · รหัสอื่น | ไม่กู้ — แปลว่าเราประกอบข้อความผิด retry ก็ผิดซ้ำเดิม ต้องแก้โค้ด |
5. โค้ดหลัก 2 ชิ้น (ถ้าจะเขียนเองบน stack อื่น)
// hash ของ body: body ว่าง = hash ของศูนย์ไบต์
const bytes = new TextEncoder().encode(body ?? '');
const digest = await crypto.subtle.digest('SHA-256', bytes);
const bodyHashB64 = toBase64(new Uint8Array(digest));
// ข้อความที่เซ็น — ต้องตรงกับฝั่ง server ทุกตัวอักษร
const stringToSign = ['v1', timestamp, method.toUpperCase(), path, query, bodyHashB64].join('\n');
// เซ็น — WebCrypto คืน raw r‖s 64 ไบต์อยู่แล้ว ตรงกับที่ฝั่ง .NET ต้องการ (ไม่ใช่ DER)
const signature = await crypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' }, privateKey, new TextEncoder().encode(stringToSign));
// ลงทะเบียน — ส่งเฉพาะครึ่งสาธารณะ
const jwk = await crypto.subtle.exportKey('jwk', publicKey);
await http.post(registerUrl, { publicKeyJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } });
6. เวลาลายเซ็นไม่ตรง ให้ debug แบบนี้
เปิด DebugResponse ฝั่ง server (เฉพาะ dev) แล้วเทียบ ทีละบรรทัด ของข้อความที่แต่ละฝั่งประกอบได้ — จุดที่ต่างกันมักเป็น 1 ใน 3 นี้: path (prefix ถูกตัดไม่เท่ากัน) · query (เรียงลำดับ/encode ต่างกัน) · body (ฝั่งหนึ่ง serialize แล้วมีช่องว่างต่าง)
ถ้าใช้ Git Bash บน Windows ทดสอบด้วย
curlระวัง shell แปลง/api/...เป็น path แบบ Windows ให้อัตโนมัติ ทำให้ข้อความที่เซ็นเพี้ยนโดยไม่มีอะไรฟ้อง — ใส่MSYS_NO_PATHCONV=1นำหน้าคำสั่ง
7. Checklist
-
bodySignatureInterceptorอยู่หลัง interceptor ที่ทำ retry -
stripPathPrefixesตรงกับ prefix ที่ gateway ตัดจริง - timestamp เป็นวินาที และสร้างใหม่ทุกครั้งที่เซ็น
- URL ลงทะเบียนกุญแจไม่ถูกเซ็น
- ไม่ cache promise ที่ reject
- กุญแจสร้างด้วย
extractable: falseและไม่มีที่ไหนส่งdขึ้น server - ทดสอบตาม ทดสอบว่าใช้งานได้จริง