เก็บ secret ให้ถูก: AES-256-GCM ที่ app layer, envelope มี version, และ rotation ที่ไม่ล่ม
pattern จัดการ secret at rest ที่ผมใช้ซ้ำได้ข้ามระบบ payment และ SaaS — เข้ารหัสที่ app layer, envelope ที่ self-describing ให้ migrate ได้แบบ zero-downtime, และ key rotation ที่ไม่ต้องมี flag day
อ่าน ~8 นาที
ทำไมต้องเป็น authenticated encryption
ครั้งแรกที่ผมต้องเก็บ API secret ของ payment provider ลง DB ในระบบ SaaS ไทยแห่งหนึ่ง ผมเกือบพลาดด้วยการหยิบ AES-256-CBC มาใช้เฉยๆ ปัญหาของ CBC (หรือ mode ที่ไม่ authenticated ใดๆ) คือมันให้แค่ confidentiality — ข้อมูลถูกอ่านไม่ได้ก็จริง แต่ถ้ามีใครไปแก้ ciphertext ใน DB คุณจะไม่รู้เลย มันจะ decrypt ออกมาเป็น garbage เงียบๆ หรือแย่กว่านั้นคือถูก bit-flip attack ควบคุมผลลัพธ์ได้
สิ่งที่ผมยึดเป็นหลักตั้งแต่นั้นคือใช้ AES-256-GCM ซึ่งเป็น authenticated encryption: นอกจากได้ confidentiality แล้วยังได้ integrity ผ่าน authentication tag ทุกครั้งที่ decrypt ถ้า ciphertext หรือ tag ถูกแตะแม้แต่ byte เดียว การ decrypt จะ throw ทันที — tamper แล้ว fail loud ไม่ใช่ fail silent
กติกาสำคัญ 2 ข้อของ GCM ที่ห้ามพลาด:
- IV (nonce) ต้อง unique ต่อ record เสมอ — สุ่มใหม่ทุกครั้งที่เข้ารหัส ห้าม reuse IV กับ key เดิมเด็ดขาด เพราะ nonce reuse ใน GCM ทำลาย security ทั้งหมด (กู้ keystream ได้ + ปลอม tag ได้)
- เก็บ auth tag ไว้ด้วย — tag เป็นส่วนหนึ่งของ output ที่ต้องใช้ตอน decrypt ไม่ใช่ของทิ้งได้
เข้ารหัสที่ app layer ไม่ใช่ใน SQL
เวลาพูดถึงเข้ารหัสใน DB หลายคนจะนึกถึง pgcrypto แล้วเขียน pgp_sym_encrypt(data, 'mykey') ใน query เลย ผมเลี่ยงวิธีนี้เสมอด้วยเหตุผลเดียวที่หนักพอ: key จะหลุดเข้า query log
ทุก statement ที่มี key เป็น literal มีโอกาสโผล่ใน pg_stat_statements, slow query log, APM trace, หรือ error report ของ ORM คุณควบคุมไม่ได้ว่าใครจะ turn on logging เมื่อไหร่ พอเข้ารหัสที่ app layer แทน key จะอยู่ใน environment variable ของ process เท่านั้น ไม่เคยเดินทางผ่าน SQL และไม่เคยเข้าไปนั่งใน DB (ซึ่งเป็นที่ที่ ciphertext อยู่ — เก็บ key ไว้ที่เดียวกับ ciphertext ก็เหมือนแปะกุญแจไว้ข้างประตู)
// crypto.ts — encrypt ที่ app layer, key มาจาก env เท่านั้น
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
const KEY = process.env.SECRET_ENC_KEY
? Buffer.from(process.env.SECRET_ENC_KEY, 'base64')
: null;
function assertKey(k: Buffer | null): asserts k is Buffer {
if (!k) throw new Error('SECRET_ENC_KEY not set');
if (k.length !== 32) throw new Error('SECRET_ENC_KEY must be 32 bytes (AES-256)');
}
export function encrypt(plaintext: string): string {
assertKey(KEY);
const iv = randomBytes(12); // 96-bit nonce ต่อ record
const cipher = createCipheriv('aes-256-gcm', KEY, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// envelope: version : iv : tag : ciphertext (base64 ทั้งหมด)
return ['v1', iv.toString('base64'), tag.toString('base64'), ct.toString('base64')].join(':');
}
Envelope ที่ self-describing: กุญแจสู่การ migrate แบบไม่ล่ม
สังเกต output ข้างบน: v1:iv:tag:ct ผมไม่เคยเก็บ ciphertext เปล่าๆ แต่ห่อมันใน envelope ที่ บอกตัวเองได้ว่ามันคืออะไร ค่านี้แก้ปัญหาที่เจ็บที่สุดของ migration จริง: ตอนที่คุณเพิ่งเปิด encryption กับคอลัมน์ที่ มีข้อมูล plaintext เก่าอยู่แล้ว
ถ้า envelope self-describing คุณจะให้ plaintext legacy กับ ciphertext ใหม่อยู่คอลัมน์เดียวกันได้ระหว่างช่วง migrate — decrypt แค่ดู prefix:
export function decrypt(stored: string): string {
const parts = stored.split(':');
// legacy row ที่ยังไม่เคยเข้ารหัส → คืนตามเดิม (backward compatible)
if (parts[0] !== 'v1') return stored;
// strict parser: envelope v1 ต้องมี 4 ส่วนครบและไม่ว่าง
const [, ivB64, tagB64, ctB64] = parts;
if (parts.length !== 4 || !ivB64 || !tagB64 || !ctB64) {
throw new Error('malformed envelope'); // v1::: = สัญญาณ IV/nonce reuse หรือ data พัง
}
assertKey(KEY);
const decipher = createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8');
}
จุดที่คนมองข้ามคือ strict parser ต้อง reject envelope ที่หน้าตาประหลาด envelope อย่าง v1::: (มี prefix แต่ field ว่าง) เป็นสัญญาณอันตราย — อาจแปลว่ามี code path ไหนสร้าง IV ว่างหรือ tag ว่างขึ้นมา ซึ่งนำไปสู่ nonce reuse ได้ ผม fail loud ตรงนี้เสมอ ดีกว่าปล่อยให้ decrypt garbage แล้วเข้าใจผิดว่าใช้งานได้
No-op เมื่อไม่มี key: แยก deploy ออกจาก rollout
บทเรียนที่ทำให้ deploy ราบรื่นขึ้นมากคือ ให้ layer encryption no-op เมื่อยังไม่มี key — encrypt คืน plaintext, decrypt คืนตามเดิม สิ่งนี้แยก "deploy code" ออกจาก "rollout key" เป็นคนละ step:
- Deploy โค้ดที่รู้จัก encryption ขึ้น production ก่อน โดยยังไม่ต้องตั้ง key — ระบบทำงานปกติเหมือนเดิม
- ค่อยตั้ง
SECRET_ENC_KEYทีหลังเมื่อพร้อม แล้ว data ใหม่จะเริ่มถูกเข้ารหัสเอง โดย row เก่ายังอ่านได้ (เพราะ envelope backward compatible)
แต่ระวังกับดัก: "no-op เมื่อไม่มี key" ต้องต่างจาก "misconfig key" ให้ชัด ถ้ามี key แต่ผิดความยาว (เช่น 16 byte แทนที่จะเป็น 32) นั่นไม่ใช่ no-op — นั่นคือ error ที่ต้อง fail loud ตอน boot ไม่ใช่ปล่อยให้รันไปแล้วพังตอนมี request จริง สังเกตว่า assertKey ข้างบนเช็ค length !== 32 เพื่อ throw ทันที การเงียบตอน misconfig คือหนทางสู่การเข้ารหัสด้วย key ที่ไม่ได้ตั้งใจ
Rotation แบบ zero-downtime: dual-secret + grace window
วันหนึ่งคุณจะต้องหมุน key (rotate) — อาจเพราะสงสัยว่ารั่ว หรือแค่ policy หมุนทุกไตรมาส ถ้า swap key ทีเดียว data เก่าที่เข้ารหัสด้วย key เดิมจะอ่านไม่ออกทันที = ล่ม วิธีที่ผมใช้คือ dual-secret validation กับ grace window: รับ key ปัจจุบันไว้ 1 ตัว และ previous key อีก 1 ตัวพร้อม expiresAt
const CURRENT = keyFromEnv('SECRET_ENC_KEY');
const PREVIOUS = process.env.SECRET_ENC_KEY_PREV
? { key: keyFromEnv('SECRET_ENC_KEY_PREV'),
expiresAt: new Date(process.env.SECRET_ENC_KEY_PREV_EXPIRES!) }
: null;
function decryptionKeys(): Buffer[] {
const keys = [CURRENT];
// enforce expiry ใน accessor — caller bypass ไม่ได้
if (PREVIOUS && PREVIOUS.expiresAt > new Date()) keys.push(PREVIOUS.key);
return keys; // ลอง current ก่อน แล้วค่อย previous
}
ระหว่าง grace window: เข้ารหัสใหม่ด้วย CURRENT เสมอ แต่ decrypt ลองทั้ง current และ previous ทำให้ค่อยๆ re-encrypt data เก่ามาเป็น key ใหม่ได้โดยไม่ล่ม พอ window ปิด (previous หมดอายุ) ก็ถอด previous ออก กุญแจสำคัญคือ enforce expiry ไว้ใน accessor ไม่ใช่ปล่อยให้ caller เป็นคนเช็ค — ถ้า caller bypass ได้ previous key จะยังใช้ได้เกินกำหนด ซึ่งลบล้างเหตุผลของการ rotate ทั้งหมด
โบนัส: lazy migration ใช้กับ password hash ได้ด้วย
หลักคิด "อ่านของเก่าได้ เขียนของใหม่ทีละน้อย" ไม่ได้จำกัดแค่ encryption ผมใช้ pattern เดียวกันตอน upgrade password hashing ทั้ง user base จาก algorithm เก่ามาเป็น bcrypt โดยไม่ต้องมี flag day และไม่ต้อง force reset password ใคร:
async function login(user, password) {
let ok = false;
if (user.hash.startsWith('$2')) { // bcrypt อยู่แล้ว
ok = await bcrypt.compare(password, user.hash);
} else { // legacy hash เก่า
ok = legacyVerify(password, user.hash);
if (ok) {
// login สำเร็จ = เรามี plaintext password ในมือชั่วขณะ → re-hash เลย
user.hash = await bcrypt.hash(password, 12);
await user.save();
}
}
return ok;
}
ทุกครั้งที่ user login สำเร็จด้วย hash เก่า เรามี plaintext password อยู่ในมือพอดี จึง re-hash เป็น bcrypt แล้วบันทึกทับ ผลคือ active user ทั้งหมดค่อยๆ ถูก upgrade เองตามการใช้งานจริง โดยไม่มีช่วง downtime และไม่รบกวนใคร คนที่ไม่เคย login เลยก็ไม่เป็นไร — hash เก่ายัง verify ได้อยู่
สรุป
- ใช้ AES-256-GCM ไม่ใช่ mode ที่ไม่ authenticated — ได้ทั้ง confidentiality และ integrity, tamper แล้ว decrypt fail loud
- IV unique ต่อ record เสมอ และเก็บ auth tag ไว้ด้วย — nonce reuse ใน GCM คือหายนะ
- เข้ารหัสที่ app layer ไม่ใช่ใน SQL — key อยู่ใน env เท่านั้น ไม่หลุดเข้า query log และไม่นั่งอยู่ที่เดียวกับ ciphertext
- Envelope self-describing (v1:iv:tag:ct) — ให้ plaintext เก่ากับ ciphertext ใหม่อยู่คอลัมน์เดียวกันตอน migrate; strict parser reject envelope ประหลาด
- No-op เมื่อไม่มี key เพื่อแยก deploy ออกจาก rollout — แต่ misconfig key (ผิดความยาว) ต้อง fail loud ตอน boot
- Rotation = dual-secret + grace window — enforce expiry ไว้ใน accessor ให้ caller bypass ไม่ได้
- Lazy migration ใช้ได้ทั้ง key rotation และ password re-hash — upgrade ทั้ง user base โดยไม่ต้องมี flag day




