Private Docs

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 ทำให้แล้ว อะไรต้องเขียนเอง

ชิ้นอยู่ที่ไหนต้องทำอะไร
ตรวจลายเซ็น ประกอบข้อความมาตรฐาน กันยิงซ้ำ ตัดสิน 401SupApp_util_lib10.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‖s 64 ไบต์ แล้วเข้ารหัส 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 กลุ่มเท่านั้น:

  1. เส้นลงทะเบียนกุญแจเอง
  2. เส้นออก token / webhook ที่ยังไม่มีตัวตนผู้ใช้
  3. เส้นเปิดเผยสาธารณะ (เช่น jwks)
  4. health check
  5. เส้นที่ตั้งใจให้เรียกโดยไม่มี 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:Enabledappsettings + IaC ทุก envเริ่มที่ false ทุก env
ClientSignature:Modeappsettings + IaC ทุก envเริ่มที่ LogOnly เสมอ
ClientSignature:DebugResponseappsettings + IaC ทุก envfalse ใน 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 เคส