Private Docs

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
  • ทดสอบตาม ทดสอบว่าใช้งานได้จริง