Trong một app mà dữ liệu được sửa từ nhiều thiết bị, sớm muộn bạn sẽ cần trả lời câu hỏi "ai đã đổi cái này, lúc nào, từ đâu?". Một bảng audit_log trả lời được câu đó. Nhưng còn một câu hỏi khó hơn: làm sao biết chính bảng audit log chưa bị sửa?
Bài này đi qua cách làm một audit log tamper-evident (sửa thì bị phát hiện) trong PostgreSQL, theo từng lớp, lớp sau bịt chỗ hở của lớp trước. Ví dụ lấy từ Kế hoạch tài chính, app lập kế hoạch thu chi của mình (cơ chế đồng bộ của app nằm trong bài đồng bộ cho PWA local-first), nơi mỗi bản ghi mà thiết bị đẩy lên đều được ghi lại kèm bản trước và sau, thiết bị, thời điểm và kết quả.
Lớp 1: chỉ cho thêm, không cho sửa
Bước đầu tiên là để database tự từ chối mọi thao tác sửa hay xoá trên bảng log:
CREATE FUNCTION audit_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'audit_log is append-only';
END $$;
CREATE TRIGGER audit_log_no_change BEFORE UPDATE OR DELETE ON audit_log
FOR EACH ROW EXECUTE FUNCTION audit_log_append_only();
CREATE TRIGGER audit_log_no_truncate BEFORE TRUNCATE ON audit_log
FOR EACH STATEMENT EXECUTE FUNCTION audit_log_append_only();
Đừng quên TRUNCATE: như tài liệu TRUNCATE của PostgreSQL ghi rõ, nó không kích hoạt trigger DELETE, nên cần một trigger riêng ở mức statement.
Dòng log nên được ghi trong cùng transaction với thay đổi dữ liệu. Như vậy không thể có thay đổi nào được lưu mà thiếu dòng log, và cũng không có dòng log nào cho một thay đổi đã bị rollback.
Lớp này chặn được gì: lỗi trong code, migration viết sai, một câu UPDATE quên WHERE. Không chặn được: người có quyền sở hữu bảng, vì họ có thể tắt trigger. Vì thế cần lớp tiếp theo.
Lớp 2: chuỗi hash
Mỗi dòng lưu thêm hash của dòng trước nó, và hash của chính nó được tính trên nội dung cộng với hash trước đó:
hash = sha256(prevHash + content)
Sửa một dòng ở giữa thì hash của dòng đó thay đổi, và mọi dòng phía sau không còn khớp nữa. Khi kiểm tra, chỉ cần đi lại chuỗi từ đầu là biết nó đứt ở đâu.
Nên làm một chuỗi cho mỗi người dùng, thay vì một chuỗi chung cho cả bảng. Như vậy các lần ghi của người dùng khác nhau không phải xếp hàng chờ nhau, và kiểm tra lịch sử của một người không phải đọc lịch sử của tất cả mọi người.
Chỗ hở: SHA-256 thì ai cũng tính được. Người có quyền ghi database có thể sửa một dòng rồi tính lại toàn bộ chuỗi phía sau. Chuỗi mới vẫn hoàn toàn hợp lệ.
Lớp 3: HMAC với khoá nằm ngoài database
Thay SHA-256 bằng HMAC (RFC 2104), loại hash có khoá bí mật, ở đây là một khoá không nằm trong database:
import { createHmac } from "node:crypto"
const entryHash = (key: string, prevHash: string, content: string) =>
createHmac("sha256", key).update(prevHash).update(content).digest("hex")
Giờ người chỉ có quyền ghi database (database bị lộ mật khẩu, một bản backup bị lấy ra, một tài khoản quản trị DB bị chiếm) không thể tạo ra hash hợp lệ cho một lịch sử đã sửa. Muốn viết lại lịch sử mà không bị phát hiện, họ cần cả quyền ghi database lẫn khoá.
Mỗi dòng được kiểm tra lại khi đọc ra, và có một endpoint kiểm tra cả chuỗi, chỉ ra chính xác dòng nào bị đứt.
Lớp 4: phát hiện việc cắt bỏ phần cuối
Chuỗi hash có một điểm mù: nếu ai đó xoá N dòng cuối cùng, phần còn lại vẫn là một chuỗi hoàn toàn hợp lệ. Không có gì "đứt" cả.
Cách xử lý: mỗi thiết bị nhớ dòng mới nhất mà nó đã kiểm tra. Lần kiểm tra sau, dòng đó phải vẫn còn và vẫn y nguyên. Nếu nó biến mất, tức là lịch sử đã bị cắt ngắn. Đây là ý tưởng "neo" trạng thái ra một nơi nằm ngoài tầm với của database, giống cách các hệ thống transparency log yêu cầu client ghi nhớ trạng thái đã thấy, và cũng là cách app chat E2EE phát hiện server giấu tin.
Nâng cấp thuật toán mà không mở ra lỗ hổng
Thêm HMAC sau khi app đã chạy một thời gian nghĩa là bảng có cả dòng cũ (SHA-256) lẫn dòng mới (HMAC). Mỗi dòng mang một cột chain_version để biết cách kiểm tra.
Nhưng phải cẩn thận với tấn công hạ cấp: nếu chấp nhận dòng phiên bản cũ ở bất cứ đâu, kẻ tấn công chỉ cần chèn thêm dòng "phiên bản cũ", loại mà ai cũng tính được hash. Quy tắc để chặn rất đơn giản: khi chuỗi đã có một dòng phiên bản mới, mọi dòng phiên bản cũ đứng sau nó đều là giả.
const forged = seenV2 && row.chainVersion < 2
Riêng tư và bất biến: hai yêu cầu ngược nhau
Audit log nên ghi IP và trình duyệt để phát hiện thiết bị lạ. Nhưng giữ IP mãi mãi thì không tốt cho quyền riêng tư; nên xoá sau một thời gian. Vậy mà bảng lại "chỉ cho thêm", và hash thì phủ lên cả IP.
Lời giải gồm hai phần:
- Hash không phủ lên IP và trình duyệt thô, mà phủ lên một HMAC của hai giá trị đó. Xoá giá trị thô đi thì hash vẫn khớp, vì HMAC vẫn còn đó.
- Trigger cho phép đúng một loại cập nhật: đặt
ipvàuser_agentvềNULL, với điều kiện mọi cột khác giữ nguyên.
CREATE OR REPLACE FUNCTION audit_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD.chain_version >= 2
AND NEW.ip IS NULL AND NEW.user_agent IS NULL
AND (to_jsonb(NEW) - 'ip' - 'user_agent') = (to_jsonb(OLD) - 'ip' - 'user_agent') THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'audit_log is append-only';
END $$;
Mẹo to_jsonb(row) - 'cột' (toán tử jsonb - text xoá một key khỏi object JSON) giúp so sánh "mọi cột trừ hai cột này" mà không phải liệt kê từng cột. Nhờ đó trigger không bị lỗi thời khi bảng có thêm cột mới.
Từ log tới tính năng cho người dùng
Có log tốt rồi thì dựng thêm tính năng bên trên khá rẻ:
- Trang Lịch sử: lọc theo loại dữ liệu, bản ghi, thiết bị, ngày. Xem ai đổi gì, từ đâu, và khôi phục bản trước.
- Nhóm thay đổi: một thao tác ghi nhiều bản ghi một lúc (ví dụ một khoản chi áp cho mọi tháng sau) hiện thành một dòng, và khôi phục được cả nhóm.
- Cảnh báo thiết bị lạ: một thiết bị chưa từng thấy sửa dữ liệu thì các thiết bị khác được báo.
Giới hạn
Cần nói rõ thiết kế này bảo vệ trước ai. Nó bảo vệ trước người có quyền với database mà không có khoá. Server ứng dụng thì giữ khoá, nên nếu chính server bị chiếm quyền thì lịch sử vẫn có thể bị viết lại. Muốn bảo vệ cả trường hợp đó, các hệ thống nghiêm ngặt hơn định kỳ công bố hash đầu chuỗi ra một nơi độc lập, hoặc ký bằng khoá nằm trong một dịch vụ quản lý khoá (KMS) mà server chỉ được gọi chứ không đọc được.
Với một app cá nhân, ba lớp đầu đã nâng chi phí của việc sửa lịch sử lên rất nhiều, mà mỗi lần ghi chỉ tốn thêm một phép HMAC.
Tóm tắt
- Cho bảng log chỉ được thêm bằng trigger trên
UPDATE,DELETEvàTRUNCATE, và ghi mỗi dòng log trong cùng transaction với thay đổi nó ghi lại. - Chuỗi hash theo từng người dùng chỉ ra chính xác chỗ lịch sử bị sửa, nhưng chuỗi SHA-256 thuần thì ai có quyền ghi cũng tính lại được.
- HMAC với khoá nằm ngoài database khiến việc viết lại lịch sử mà không bị phát hiện đòi hỏi cả quyền ghi database lẫn khoá.
- Chuỗi không thấy được việc phần cuối bị cắt, nên mỗi thiết bị nhớ dòng mới nhất nó đã kiểm tra và xem dòng đó còn không.
- Khi nâng cấp thuật toán, mọi dòng phiên bản cũ đứng sau một dòng phiên bản mới đều coi là giả.
- Hash phủ lên HMAC của IP và trình duyệt thay vì giá trị thô, để sau này xoá được giá trị thô qua đúng một loại cập nhật mà trigger cho phép.
- Không lớp nào bảo vệ được khi chính server ứng dụng bị chiếm quyền, vì server giữ khoá.
Nếu bạn đã làm audit log chống sửa theo một cách khác, mình rất muốn được trao đổi. Email của mình ở trang chủ.