BE 2 — Body Signature: บังคับให้ทุกคำขอมีลายเซ็นของผู้ใช้
กลไกใหญ่ที่สุดของงานรอบนี้ — ผู้ใช้แต่ละคนถือกุญแจของตัวเอง เซ็นทุกคำขอ ทำให้ปลอมคำขอ แก้ระหว่างทาง หรือยิงซ้ำไม่ได้ · อะไรอยู่ใน library แล้ว อะไรที่ service ต้องเขียนเอง พร้อมโค้ดจริงและลำดับเปิดใช้ที่ไม่ทำผู้ใช้พัง
อัปเดต: 2026-08-23
ต้องทำคู่กับฝั่ง frontend เสมอ — ดู FE — Body Signature · ทำฝั่งเดียวเปิดใช้จริงไม่ได้
1. มันแก้ปัญหาอะไร
Token อย่างเดียวพิสูจน์แค่ว่า “คนนี้ login แล้ว” — ถ้ามีใครขโมย token ไปได้ เขายิงคำขออะไรก็ได้ในนามผู้ใช้คนนั้น
ลายเซ็นเพิ่มอีกชั้น: ทุกคำขอถูกเซ็นด้วย กุญแจส่วนตัวที่อยู่ในเครื่องผู้ใช้เท่านั้น และกุญแจนั้นถูกดึงออกมาไม่ได้แม้แต่โดยโค้ดของเราเอง
| ถ้าคนร้าย… | ผลลัพธ์ |
|---|---|
| ขโมย token ไปยิงจากเครื่องตัวเอง | เซ็นไม่ได้ (ไม่มีกุญแจ) → 401 |
| ดักแก้ body ระหว่างทาง | ลายเซ็นไม่ตรงกับ body ที่ถูกแก้ → 401 |
| อัดคำขอเดิมไว้แล้วยิงซ้ำ | ระบบจำได้ว่าคำขอนี้เคยผ่านแล้ว → 401 |
เทคนิคที่ใช้: ECDSA เส้นโค้ง P-256 + SHA-256 — มาตรฐานเดียวกับที่ browser รองรับเองผ่าน WebCrypto ไม่ต้องลง library เสริมฝั่งหน้าบ้าน
2. เส้นแบ่ง: อะไร library ทำให้แล้ว อะไรต้องเขียนเอง
| ชิ้น | อยู่ที่ไหน | ต้องทำอะไร |
|---|---|---|
| ตรวจลายเซ็น ประกอบข้อความมาตรฐาน กันยิงซ้ำ ตัดสิน 401 | SupApp_util_lib ≥ 10.12.1 | แค่ bump version |
ที่เก็บกุญแจของผู้ใช้ (ISignatureKeyResolver) | service เขียนเอง | library ตั้งใจไม่ตัดสินให้ว่ากุญแจอยู่ที่ไหน |
ที่จำคำขอที่ผ่านแล้ว (IReplaySignatureStore) | service เขียนเอง | ไม่ลงทะเบียน = ไม่มีการกันยิงซ้ำ (ไม่ error ไม่เตือน) |
| endpoint ให้ผู้ใช้ลงทะเบียนกุญแจ | service เขียนเอง | เส้นเดียวที่ห้ามบังคับเซ็น |
3. ต่อสาย 4 ขั้น
3.1 bump library + ลงทะเบียนใน DI
services.AddClientSignature(options =>
configuration.GetSection("ClientSignature").Bind(options));
// library จงใจไม่ register 2 ตัวนี้ให้ — service เป็นคนบอกว่าของอยู่ที่ไหน
services.AddScoped<ISignatureKeyResolver, RedisClientKeyStore>();
services.AddScoped<IReplaySignatureStore, RedisReplaySignatureStore>();
ตัวเลือกทั้งหมด (ค่าที่เห็นคือค่า default ของ library):
public class ClientSignatureOptions
{
public bool Enabled { get; set; } // kill switch — default false
public ClientSignatureMode Mode { get; set; } // LogOnly (default) | Enforce
public bool DebugResponse { get; set; } // ตอบส่วนประกอบกลับไปเทียบ (ปิดใน production เสมอ)
public string SignatureHeaderName { get; set; } // "X-Signature"
public string TimestampHeaderName { get; set; } // "X-Timestamp"
public int TimestampToleranceSeconds { get; set; } // 300
}
3.2 แขวน filter ให้ทุกเส้นโดย default (fail-closed)
ไล่ใส่ attribute ทีละเส้นแปลว่า เส้นที่เพิ่มพรุ่งนี้จะไม่ถูกป้องกัน วิธีที่ใช้จริงคือแขวนอัตโนมัติทุก action แล้วให้ การยกเว้น เป็นสิ่งที่ต้องตั้งใจทำ
public sealed class ClientSignatureConvention : IApplicationModelConvention
{
public void Apply(ApplicationModel application)
{
foreach (var controller in application.Controllers)
foreach (var action in controller.Actions)
ApplyToAction(action);
}
private static void ApplyToAction(ActionModel action)
{
// ข้ามเมื่อ (ก) ประกาศยกเว้นไว้ หรือ (ข) แขวน filter เองอยู่แล้ว
// (ข) สำคัญมาก: แขวนซ้ำ 2 ชั้น = ตรวจลายเซ็น 2 รอบต่อคำขอเดียว
// รอบสองจะไปชนกลไกกันยิงซ้ำของคำขอตัวเอง แล้วเด้ง 401 ทั้งที่ลายเซ็นถูก
if (HasSkipAttribute(action) || HasExplicitServiceFilter(action)) return;
action.Filters.Add(new ServiceFilterAttribute(typeof(ClientSignatureValidationFilter)));
}
private static bool HasSkipAttribute(ActionModel action) =>
action.Attributes.OfType<SkipClientSignatureAttribute>().Any()
|| action.Controller.Attributes.OfType<SkipClientSignatureAttribute>().Any();
private static bool HasExplicitServiceFilter(ActionModel action) =>
action.Attributes.OfType<ServiceFilterAttribute>()
.Any(f => f.ServiceType == typeof(ClientSignatureValidationFilter));
}
// Program.cs — ภายใน AddControllers(...)
options.Conventions.Add(new ClientSignatureConvention());
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)]
public sealed class SkipClientSignatureAttribute : Attribute { }
3.3 เขียนที่เก็บกุญแจ (ตัวอย่างที่ใช้จริง: Redis)
public interface ISignatureKeyResolver
{
Task<ClientPublicKeyJwk?> ResolveAsync(string subjectId, CancellationToken ct = default);
Task RenewAsync(string subjectId, CancellationToken ct = default); // ต่ออายุ — ล้มเหลวได้ ห้ามทำให้คำขอพัง
}
public sealed class ClientPublicKeyJwk
{
public string Kty { get; set; } = "EC"; // ต้องเป็น EC
public string Crv { get; set; } = "P-256"; // ต้องเป็น P-256
public string X { get; set; } = string.Empty; // base64url, ถอดแล้ว 32 ไบต์
public string Y { get; set; } = string.Empty;
}
ข้อตกลงที่ใช้จริงในของที่ทำเสร็จแล้ว:
| เรื่อง | ค่าที่ใช้ | เหตุผล |
|---|---|---|
| Redis key ของกุญแจ | {ServiceName}:cache:client-key:{subjectId} | มี prefix ของ service เสมอ กัน env/service ปนกัน |
| อายุกุญแจ | 7 วัน ต่ออายุเมื่อมีการใช้งาน | กุญแจที่ไม่ถูกใช้หายไปเอง |
| การเขียน | เขียนได้เฉพาะเมื่อ ยังไม่มี ค่าเดิม | ลงทะเบียนซ้ำได้ 409 ไม่ใช่แอบทับกุญแจเดิม |
| Redis key ของการกันยิงซ้ำ | {ServiceName}:cache:replay:{subjectId}:{requestId} | requestId = hash ของข้อความที่ประกอบแล้ว |
🔴 ข้อจำกัดที่ต้องรู้ก่อนเปิด Enforce: ตอนนี้ 1 ผู้ใช้ = 1 กุญแจเท่านั้น — ถ้าผู้ใช้ล้างข้อมูลเบราว์เซอร์ หรือเปลี่ยนไปใช้อีกเครื่อง/อีกเบราว์เซอร์ เครื่องใหม่จะสร้างกุญแจใหม่แล้วลงทะเบียนไม่ผ่าน (
409เพราะกุญแจเดิมยังไม่หมดอายุ) ⇒ ผู้ใช้คนนั้นเซ็นไม่ได้จนกว่ากุญแจเดิมจะหมดอายุ (7 วัน) ซึ่งภายใต้โหมด Enforce แปลว่าใช้งานไม่ได้เลย ⇒ ระหว่างที่ยังไม่มีการรองรับหลายอุปกรณ์ (อยู่ในรายการ ของที่ยังไม่ทำ) ให้ อยู่ในโหมดLogOnlyให้นานพอ และเตรียมวิธีล้างกุญแจของผู้ใช้รายคนไว้สำหรับ support
⚠️
subjectIdต้องเป็นค่าเดียวกันเป๊ะกับที่ filter ใช้ค้นหา — ในของจริงคือ object id จาก token ซึ่งมาได้ 2 ชื่อ claim (oidและชื่อเต็มแบบ URI) ฝั่งลงทะเบียนต้องอ่านทั้ง 2 ชื่อด้วยลำดับเดียวกับ filter ไม่งั้นกุญแจถูกเขียนลงช่องที่ไม่มีใครไปอ่าน อาการคือSIG_KEY_NOT_FOUNDทั้งที่เพิ่งลงทะเบียนสำเร็จ
3.4 เปิด endpoint ลงทะเบียนกุญแจ
[HttpPost]
[SkipClientSignature] // ยกเว้นถาวร: จะเซ็นด้วยกุญแจที่ยังไม่ได้ลงทะเบียนไม่ได้
public async Task<IActionResult> Register(
[FromBody] RegisterClientKeyRequest request, CancellationToken ct)
{
var subjectId = ResolveObjectId(); // ต้อง mirror ตรรกะของ filter เป๊ะ
if (string.IsNullOrEmpty(subjectId))
return ApiMatch(Result.Failure<RegisterClientKeyResponse>(Error.Unauthorized(
"CLIENT_KEY_NO_SUBJECT", "The access token carries no object id.")));
return ApiMatch(await _mediator.Send(
new RegisterClientKeyCommand(subjectId, request?.PublicKeyJwk), ct));
}
public sealed record RegisterClientKeyRequest(ClientKeyJwkRequest? PublicKeyJwk);
public sealed record ClientKeyJwkRequest(string Kty, string Crv, string X, string Y, string? D = null);
public sealed record RegisterClientKeyResponse(string Status);
🔴 ถ้า client ส่ง
D(ส่วนที่เป็นกุญแจลับ) มาด้วย ให้ปฏิเสธคำขอ — ฝั่ง server ไม่มีเหตุผลที่ต้องรู้กุญแจลับของผู้ใช้ การรับไว้เฉย ๆ คือการสร้างที่เก็บความลับที่ไม่มีใครต้องการ
4. ข้อความที่ถูกเซ็น (ต้องตรงกับฝั่ง client ทุกตัวอักษร)
v1\n{timestamp}\n{METHOD}\n{path}\n{query}\n{base64(sha256(body))}
- คั่นด้วย
\nเท่านั้น ·METHODตัวใหญ่ทั้งหมด - body ว่าง = hash ของ ศูนย์ไบต์ ไม่ใช่สตริงว่าง และไม่ใช่การข้าม field
- ลายเซ็นเป็น raw
r‖s64 ไบต์ แล้วเข้ารหัส base64 — รูปแบบ DER ถูกปฏิเสธ - ถ้า path ประกอบเป็นรูปมาตรฐานไม่ได้ ระบบ ปฏิเสธคำขอ แทนที่จะเดา
5. รหัสเหตุผลที่ตอบกลับ
| รหัส | แปลว่า | ฝั่ง client ควรทำ |
|---|---|---|
SIG_MISSING_HEADER | ไม่ได้ส่ง header ลายเซ็น/เวลา | เซ็นแล้วส่งใหม่ |
SIG_TIMESTAMP_INVALID | เวลาห่างเกินที่ยอมรับ (default 300 วิ) | เซ็นใหม่ด้วยเวลาปัจจุบัน |
SIG_KEY_NOT_FOUND | ยังไม่มีกุญแจของผู้ใช้คนนี้ | ลงทะเบียนกุญแจใหม่แล้วลองอีกครั้ง |
SIG_INVALID | ลายเซ็นไม่ตรง | อย่า retry — ประกอบข้อความผิด ต้องแก้โค้ด |
SIG_MALFORMED | รูปแบบลายเซ็นผิด (เช่นส่ง DER) | แก้โค้ด |
SIG_REPLAY | คำขอนี้เคยผ่านมาแล้ว | เซ็นใหม่ด้วย timestamp ใหม่ |
SIG_KEY_STORE_UNAVAILABLE · SIG_REPLAY_STORE_UNAVAILABLE | ที่เก็บล่ม | อย่าลงทะเบียนซ้ำ — เป็นการซ้ำเติมของที่ล่มอยู่ |
6. รายการยกเว้น — ทำให้มันแก้ยากโดยตั้งใจ
เส้นที่ยกเว้นได้มี 5 กลุ่มเท่านั้น:
- เส้นลงทะเบียนกุญแจเอง
- เส้นออก token / webhook ที่ยังไม่มีตัวตนผู้ใช้
- เส้นเปิดเผยสาธารณะ (เช่น jwks)
- health check
- เส้นที่ตั้งใจให้เรียกโดยไม่มี token และ มีผู้เรียกจริงที่ยืนยันได้
แล้วผนึกรายการนั้นด้วย test — ใครเพิ่มการยกเว้นโดยไม่แก้ test ⇒ build แดง:
private static readonly HashSet<string> ApprovedExemptions = new()
{
"ClientKeys.Register", // กลุ่ม 1: ไก่กับไข่ — เส้นที่ลงทะเบียนกุญแจ
"Security.GetJwks", // กลุ่ม 3: ข้อมูลสาธารณะ (ใส่เฉพาะถ้า service คุณมีเส้นนี้จริง)
// ... หนึ่งบรรทัดต่อหนึ่งเส้น พร้อมเหตุผลว่าอยู่กลุ่มไหน
};
[Fact]
public async Task Every_action_either_carries_the_signature_filter_or_is_an_approved_exemption()
{
var provider = factory.Services.GetRequiredService<IActionDescriptorCollectionProvider>();
var actions = provider.ActionDescriptors.Items.OfType<ControllerActionDescriptor>().ToList();
Assert.NotEmpty(actions); // กันเคสที่ test เขียวเพราะหา action ไม่เจอสักตัว
// เงื่อนไขที่ต้องเช็ค 2 ทาง:
// 1) ทุก action ต้อง "มี filter" หรือ "อยู่ในลิสต์ยกเว้น" อย่างใดอย่างหนึ่ง
// 2) ต้องไม่มี action ที่ "ทั้งมี filter และอยู่ในลิสต์" — แปลว่าลิสต์ล้าสมัยแล้ว
}
จุดเล็กที่ทำให้ test แดงแบบงง: ASP.NET Core ตัดคำว่า
Asyncท้ายชื่อ method ออก — คีย์ในลิสต์ต้องใช้ชื่อหลังตัดแล้ว
7. Config + IaC
// appsettings.json — ค่าเริ่มต้นที่ commit ลงไปได้อย่างปลอดภัย
"ClientSignature": {
"Enabled": false,
"Mode": "LogOnly",
"DebugResponse": false
}
| key | ใส่ที่ไหน | หมายเหตุ |
|---|---|---|
ClientSignature:Enabled | appsettings + IaC ทุก env | เริ่มที่ false ทุก env |
ClientSignature:Mode | appsettings + IaC ทุก env | เริ่มที่ LogOnly เสมอ |
ClientSignature:DebugResponse | appsettings + IaC ทุก env | false ใน production |
ไม่มีค่าไหนเป็นความลับ — กุญแจสาธารณะของผู้ใช้อยู่ใน Redis ไม่ใช่ config ส่วนกุญแจลับไม่เคยออกจากเครื่องผู้ใช้ จึงไม่ต้องใช้ KeyVault สำหรับกลไกนี้
8. ลำดับการเปิดใช้ (ห้ามข้ามขั้น)
1) deploy โดย Enabled=false → พิสูจน์ว่าไม่มีอะไรพังจากการติดตั้ง
2) Enabled=true, Mode=LogOnly → ดู log ว่าใครยังเซ็นไม่ผ่าน (ยังไม่ปฏิเสธใคร)
3) frontend ปล่อยเวอร์ชันที่เซ็นแล้ว → รอจนสัดส่วนที่เซ็นถูกนิ่ง
4) Mode=Enforce → เริ่มปฏิเสธจริง
🔴 Enabled ถูกอ่านครั้งเดียวตอน start — เปลี่ยนค่าแล้วต้อง restart ไม่ใช่สวิตช์สด
9. Checklist
- bump
SupApp_util_lib≥ 10.12.1 -
AddClientSignature+ ลงทะเบียนISignatureKeyResolverและIReplaySignatureStore - แขวน filter ด้วย convention (ไม่ไล่ใส่ทีละเส้น) และกันการแขวนซ้ำ 2 ชั้น
- endpoint ลงทะเบียนกุญแจ + ปฏิเสธเมื่อมีคนส่งกุญแจลับมาด้วย
-
subjectIdฝั่งลงทะเบียนกับฝั่ง filter อ่าน claim ชื่อเดียวกันด้วยลำดับเดียวกัน - test ผนึกรายการยกเว้น +
Assert.NotEmpty - config ครบทุก env ทั้งใน repo และ IaC
- ทดสอบตาม ทดสอบว่าใช้งานได้จริง ผ่านครบ 4 เคส