Private Docs

เข้ารหัสรายฟิลด์ — โค้ดจะหน้าตาแบบไหน (เทียบ 2 ทาง)

ตัวอย่างโค้ดจริงของ 2 ทางเลือกที่ต้องตัดสิน: ทำเป็น middleware ให้ถอดอัตโนมัติก่อนเข้า API หรือ คงตัวตรวจลายเซ็นเป็น filter แล้วให้ developer เรียก method ถอดเอง — พร้อมข้อดีข้อเสียที่เห็นจากโค้ดจริง

อัปเดต: 2026-08-27

เข้ารหัสรายฟิลด์ — โค้ดจะหน้าตาแบบไหน

ตัดสินแล้ว 27/08: เลือก ทาง ก — middleware · ขอบเขต UserService เท่านั้น (Owner: “งานยาวไม่เป็นไร เพราะทำแค่ user service”) ตรวจโครงจริงหลังเลือกแล้วพบว่า ถูกกว่าที่ประเมินไว้: EcdsaClientSignatureVerifier และ CanonicalRequest เป็น public อยู่แล้ว ⇒ ไม่ต้องแตะไลบรารีกลางเลย และ ไม่ต้องแก้ DTO (ดูหัวข้อถัดไป)

หน้านี้เขียนขึ้นเพื่อตอบคำถามเดียว: “ขอเห็นตัวอย่างก่อนถึงจะตัดสินใจได้” ทั้ง 2 ทางให้ผลลัพธ์ด้านความปลอดภัยเหมือนกันเป๊ะ — ต่างกันที่ ใครเป็นคนสั่งถอด และ ถ้า developer ลืมจะเกิดอะไรขึ้น

สิ่งที่เหมือนกันทั้ง 2 ทาง

หน้าตาคำขอ (เข้ารหัสเฉพาะ field)

POST /api/user-service/v1/users
Content-Type: application/json

{
  "email": "somchai@example.com",        // ไม่เข้ารหัส — ใช้ค้นหา
  "firstNameTH": "สมชาย",                 // ไม่เข้ารหัส
  "phone": "0812345678",                 // ไม่เข้ารหัส
  "birthDate":      "eyJhbGciOiJSU0Et…AbC.DeF.GhI.JkL",   // ← JWE
  "nationality":    "eyJhbGciOiJSU0Et…MnO.PqR.StU.VwX",   // ← JWE
  "mailingAddress": "eyJhbGciOiJSU0Et…YzA.BcD.EfG.HiJ"    // ← JWE (ทั้ง object เป็นข้อความเดียว)
}

ยังเป็น JSON ปกติทุกประการ — gateway ยัง route ได้ · log ยังอ่านได้ว่าใครเรียกอะไร · Swagger ยังแสดงหน้าตา request · เห็นแค่ 3 ค่าที่เป็นข้อความยาว ๆ อ่านไม่ออก

ฝั่งหน้าบ้าน — เหมือนกันทั้ง 2 ทาง

@exim/auth-sdk มี method ให้ developer หยิบไปใช้:

import { encryptFields } from '@exim/auth-sdk/payload-encryption';

const body = await encryptFields(form, ['birthDate', 'nationality', 'mailingAddress']);
await this.http.post('/api/user-service/v1/users', body);
// interceptor ลายเซ็นทำงานต่อเอง เซ็นทับ body ที่เข้ารหัสแล้ว — developer ไม่ต้องรู้

ต้องแก้ DTO ไหม — ขึ้นกับทางที่เลือก

แก้ 27/08 หลังตรวจโครงจริง — ข้อความเดิมที่ว่า “ต้องแก้ DTO เหมือนกันทั้ง 2 ทาง” ไม่ถูกต้อง

ทาง ก (ที่เลือกแล้ว) ไม่ต้องแก้ DTO เลยแม้แต่ตัวเดียว — เพราะ middleware ถอดเสร็จก่อน MVC จะแปลง JSON เป็น object · หน้าบ้านเข้ารหัส JSON.stringify(ค่า) · middleware ถอดแล้วเอา JSON กลับเข้าไปแทนที่ ⇒ MVC เห็นเป็นค่าปกติทุกประการ ⇒ BirthDate ยังเป็นชนิดวันที่เหมือนเดิม · MailingAddress ยังเป็น object เหมือนเดิม · validator ไม่ต้องแตะ

ที่ต้องเปลี่ยนชนิดเป็นข้อความคือ ทาง ข เท่านั้น (เพราะ MVC แปลง JSON ก่อน แล้วค่อยถอดทีหลัง)

ทาง ข — ต้องเปลี่ยนชนิดก่อน แล้วแปลงกลับหลังถอด

public sealed record CreateUserRequest(
    string  Email,
    string  FirstNameTH,
    [EncryptedField] string? BirthDate,       // เดิม: DateOnly?
    [EncryptedField] string? Nationality,
    [EncryptedField] string? MailingAddress); // เดิม: CreateUserAddressRequest?

[EncryptedField] เป็นตัวบอกว่า field ไหนต้องถอด — ประกาศที่เดียว ใช้ได้ทั้ง 2 ทาง


ทาง ก — Middleware ถอดให้อัตโนมัติก่อนเข้า API

สิ่งที่ต้องทำครั้งเดียว (ทีมกลาง)

ย้าย ตัวตรวจลายเซ็น จาก action filter มาเป็น middleware ก่อน แล้ววางตัวถอดต่อท้าย

// Program.cs — ลำดับนี้คือหัวใจ ห้ามสลับ
app.UseRouting();                      // ต้องมาก่อน เพื่อให้ middleware อ่าน attribute ของ endpoint ได้
app.UseClientSignatureVerification();  // ① ตรวจลายเซ็นจาก "ไบต์ที่วิ่งมาบนสาย"
app.UsePayloadFieldDecryption();       // ② ผ่านแล้วค่อยถอด field ที่ทำเครื่องหมายไว้
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
public sealed class PayloadFieldDecryptionMiddleware(RequestDelegate next, IPayloadCipher cipher)
{
    public async Task InvokeAsync(HttpContext ctx)
    {
        // รู้ว่า endpoint นี้มี field ไหนต้องถอด จาก attribute บน DTO ของ action นั้น
        var fields = ctx.GetEndpoint()?.Metadata.GetMetadata<EncryptedFieldMap>();
        if (fields is null) { await next(ctx); return; }

        ctx.Request.EnableBuffering();
        var node = await JsonNode.ParseAsync(ctx.Request.Body);

        foreach (var name in fields.Names)
        {
            if (node?[name] is not JsonValue v) continue;
            try
            {
                node[name] = cipher.Decrypt(v.GetValue<string>());   // ค่าที่ถอดแล้วทับกลับลงไป
            }
            catch (PayloadCipherException)
            {
                await ctx.WriteProblem(400, "PayloadDecryptionError");   // fail-closed
                return;
            }
        }

        // เขียน body ใหม่ให้ MVC อ่านต่อ — ตั้งแต่จุดนี้ระบบเห็นเป็น JSON ธรรมดา
        ctx.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(node!.ToJsonString()));
        ctx.Request.Body.Position = 0;
        await next(ctx);
    }
}

สิ่งที่ developer ต้องเขียนต่อ endpoint

[HttpPost]
[Authorize]
public async Task<IActionResult> CreateUserAsync(
    [FromBody] CreateUserRequest request,          // ← กลับมาใช้ [FromBody] ได้ตามปกติ
    CancellationToken ct)
{
    var command = request.ToCommand();
    return ApiMatch(await _mediator.Send(command, ct));
}

เท่านี้จริง ๆ — controller ไม่รู้เลยว่ามีการเข้ารหัส เพราะค่าถูกถอดไปแล้วตั้งแต่ก่อนถึง MVC

ข้อดีข้อเสีย
developer ลืมไม่ได้ — ไม่มีอะไรให้ลืมต้อง รื้อชั้นลายเซ็นที่ใช้งานจริงอยู่แล้วทุก endpoint มาเป็น middleware
controller สะอาด กลับไปใช้ [FromBody]middleware อยู่นอก MVC ⇒ ต้องอ่าน attribute ผ่าน endpoint metadata (เขียนยากกว่า filter นิดหน่อย)
เพิ่ม endpoint ใหม่ = แปะ [EncryptedField] บน DTO จบถ้าลำดับใน Program.cs ถูกสลับ = พังเงียบ (ต้องมี test กันไว้)
test ได้ที่ระดับ HTTP ครบวงจรงานครั้งแรกใหญ่กว่า — กระทบทุก service ที่ใช้ชั้นลายเซ็นอยู่แล้ว

ทาง ข — คงลายเซ็นเป็น filter แล้วให้ developer เรียก method ถอด

สิ่งที่ต้องทำครั้งเดียว (ทีมกลาง)

ไม่แตะชั้นลายเซ็นเลย เพิ่มแค่ method ใน lib กลาง

public interface IPayloadFieldDecryptor
{
    /// <summary>ถอดทุก property ที่ติด [EncryptedField] แล้วคืน object เดิมที่ค่าเป็น plaintext</summary>
    T Decrypt<T>(T request);
}

สิ่งที่ developer ต้องเขียนต่อ endpoint

[HttpPost]
[Authorize]
public async Task<IActionResult> CreateUserAsync(
    [FromBody] CreateUserRequest request,
    [FromServices] IPayloadFieldDecryptor decryptor,
    CancellationToken ct)
{
    request = decryptor.Decrypt(request);          // ← บรรทัดเดียวที่ต้องจำ
    var command = request.ToCommand();
    return ApiMatch(await _mediator.Send(command, ct));
}
ข้อดีข้อเสีย
ไม่ต้องแตะของที่ใช้งานอยู่เลย — ชั้นลายเซ็นคงเดิมทั้งหมด🔴 developer ลืมได้ — ลืมบรรทัดเดียว = ciphertext ถูกบันทึกลง DB เงียบ ๆ ไม่มี error
งานครั้งแรกเล็กมาก ทำทันก่อน demo สบายต้องเขียนซ้ำทุก endpoint ที่มี field เข้ารหัส
ลำดับ “ตรวจก่อนถอด” ยังถูกต้องโดยโครงสร้าง (เนื้อ method รันหลัง filter เสมอ)reviewer ต้องคอยตรวจว่าใครลืมไหม
controller กลับไปใช้ [FromBody] ได้เหมือนกัน — Swagger ไม่หาย

🔴 ความเสี่ยงข้อเดียวที่ต้องรับให้ได้ถ้าเลือกทางนี้: ลืมเรียก Decrypt แล้วระบบ ไม่ฟ้องอะไรเลย — ข้อมูลที่บันทึกจะเป็นข้อความเข้ารหัส กว่าจะรู้ตัวคือตอนเปิดดูข้อมูลจริง

กันได้บางส่วน ด้วยการให้ validator ปฏิเสธค่าที่หน้าตาเป็น JWE:

RuleFor(x => x.BirthDate)
    .Must(v => !LooksLikeJwe(v))
    .WithMessage("ค่านี้ยังไม่ได้ถอดรหัส — ลืมเรียก IPayloadFieldDecryptor.Decrypt หรือเปล่า");

ทางที่ 3 (ถ้าอยากได้ข้อดีของทั้งคู่)

เรียก Decrypt อัตโนมัติ ด้วย pipeline behavior ของ MediatR ที่มีใช้อยู่แล้วในระบบ — ไม่ต้องรื้อชั้นลายเซ็น และ developer ก็ลืมไม่ได้

public sealed class PayloadDecryptionBehavior<TReq, TRes>(IPayloadFieldDecryptor decryptor)
    : IPipelineBehavior<TReq, TRes> where TReq : notnull
{
    public Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct)
        => next(decryptor.Decrypt(request));   // ทุก command ผ่านตรงนี้เองอยู่แล้ว
}
// สิ่งที่ developer เขียน = เท่ากับทาง ก (ไม่มีอะไรให้ลืม)
public async Task<IActionResult> CreateUserAsync([FromBody] CreateUserRequest request, CancellationToken ct)
    => ApiMatch(await _mediator.Send(request.ToCommand(), ct));
ทาง ก (middleware)ทาง ข (method)ทาง 3 (behavior)
developer ลืมได้ไหม❌ ลืมไม่ได้🔴 ลืมได้❌ ลืมไม่ได้
ต้องรื้อชั้นลายเซ็น🔴 ต้องรื้อ✅ ไม่ต้อง✅ ไม่ต้อง
ลำดับ ตรวจ → ถอด✅ โดยลำดับใน Program.cs✅ โดยโครงสร้าง pipeline✅ โดยโครงสร้าง pipeline
Swagger
งานครั้งแรกใหญ่ (ทุก service)เล็กที่สุดเล็ก (lib กลาง + register 1 บรรทัด)
ทันก่อน demo 1-2/09⚠️ เสี่ยง

สิ่งที่ต้องการจากคุณ

เลือก 1 ใน 3 — เลือกแล้วเริ่มลงมือได้ทันที เพราะส่วนที่เหลือ (กุญแจใน Key Vault, JWKS, ตัวถอดในไลบรารีกลาง, [EncryptedField], การแก้ชนิดของ BirthDate) เหมือนกันหมดทุกทาง