Hướng dẫn SQLCipher: Mã hóa toàn diện cơ sở dữ liệu SQLite cho Mobile và Desktop

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Bắt đầu nhanh (5 phút): Mã hóa SQLite với SQLCipher

Sáu tháng trước, ứng dụng desktop client của chúng tôi trượt bài kiểm tra bảo mật định kỳ. Các token nhạy cảm của khách hàng nằm dưới dạng plain text trong tệp SQLite trên ổ đĩa người dùng. Bất kỳ ai sử dụng hex editor đều có thể mở tệp .db và đọc session token dưới dạng ASCII thuần túy. Chúng tôi chuyển storage engine sang SQLCipher, và ngay trong ngày hôm đó, dữ liệu cục bộ đã được bảo vệ an toàn sau lớp mã hóa AES 256-bit.

Đối với Python và môi trường desktop, hãy cài đặt bản phân phối nhị phân:

pip install sqlcipher3-binary

Để bảo mật một database mới, bạn chỉ cần thêm hai dòng thiết lập ngay sau khi mở kết nối:

import sqlcipher3

# Mở kết nối tới tệp database
conn = sqlcipher3.connect("secure_app.db")
cursor = conn.cursor()

# Thiết lập khóa mã hóa trước khi thực thi bất kỳ truy vấn nào
cursor.execute("PRAGMA key = 'my-super-secret-passphrase';")

# Tạo bảng và chèn bản ghi như bình thường
cursor.execute("CREATE TABLE secrets (id INTEGER PRIMARY KEY, token TEXT);")
cursor.execute("INSERT INTO secrets (token) VALUES ('oauth_access_token_xyz');")
conn.commit()
conn.close()

Hãy thử kiểm tra secure_app.db bằng các công cụ SQLite tiêu chuẩn. CLI sẽ báo lỗi ngay lập tức với thông báo file is not a database. Toàn bộ bảng, index, schema và WAL journal đều được xáo trộn hoàn toàn cho đến khi bạn cung cấp đúng khóa.

Kiến trúc: Mật mã hóa theo cấp độ trang (Page-Level Cryptography)

SQLite tiêu chuẩn ghi dữ liệu thô vào các trang (page) 4.096 byte. SQLCipher can thiệp vào quá trình này tại tầng Virtual File System (VFS) của SQLite. Trước khi bất kỳ page nào được ghi xuống đĩa, SQLCipher sẽ bọc nó trong một container mã hóa đã được xác thực.

Cách SQLCipher bảo vệ từng Page

SQLCipher mã hóa database theo từng page thay vì áp dụng stream cipher lên toàn bộ tệp. Điều này giúp SQLite duy trì tốc độ cao. Việc đọc bản ghi thứ 50.000 có thời gian seek hoàn toàn tương đương với SQLite thông thường, bởi vì engine chỉ giải mã đúng page mục tiêu.

  • Thuật toán mã hóa: AES 256-bit ở chế độ CBC (Cipher Block Chaining).
  • Initialization Vector (IV) cho từng Page: Mỗi page lưu một IV ngẫu nhiên 16 byte riêng biệt. Hai page chứa dữ liệu giống hệt nhau sẽ tạo ra ciphertext hoàn toàn khác biệt trên đĩa.
  • Kiểm tra tính toàn vẹn bằng HMAC: SQLCipher 4 dành riêng 64 byte ở cuối mỗi page 4KB cho chữ ký HMAC-SHA512. Engine sẽ xác thực digest này trước khi tiến hành giải mã. Nếu kẻ tấn công thay đổi dù chỉ một bit trên đĩa, SQLCipher sẽ từ chối page đó ngay lập tức thay vì trả về dữ liệu bị hỏng.

Dẫn xuất khóa qua PBKDF2

SQLCipher không bao giờ sử dụng trực tiếp passphrase của bạn làm khóa mã hóa. Thay vào đó, nó đưa chuỗi này vào PBKDF2-HMAC-SHA512 cùng với salt 16 byte được lưu trong header của database. Trong SQLCipher 4, hàm này chạy 256.000 vòng lặp theo mặc định. Chi phí tính toán này bảo vệ các tệp offline trước các cuộc tấn công dò mật khẩu kiểu brute-force dictionary.

Quy trình trong môi trường Production: Migration, Raw Key và Mobile

Các bài hướng dẫn cơ bản chỉ đề cập đến cấu hình đơn giản, nhưng triển khai thực tế trên production lại mang đến những thách thức riêng biệt.

Chuyển đổi Database dạng Plaintext sang SQLCipher

Bạn không thể mã hóa trực tiếp một tệp SQLite có sẵn bằng PRAGMA key. Thay vào đó, hãy gắn (attach) database chưa mã hóa vào một target rỗng đã được mã hóa, sau đó sao chép dữ liệu bằng sqlcipher_export():

import sqlcipher3

# Khởi tạo target đã mã hóa
conn = sqlcipher3.connect("encrypted.db")
cursor = conn.cursor()
cursor.execute("PRAGMA key = 'ProductionPassphrase2026!';")

# Gắn database cleartext cũ vào phiên làm việc
cursor.execute("ATTACH DATABASE 'legacy_plaintext.db' AS plaintext KEY '';")

# Export toàn bộ schema và tất cả các hàng
cursor.execute("SELECT sqlcipher_export('main', 'plaintext');")
conn.commit()

# Gỡ bỏ liên kết database cũ
cursor.execute("DETACH DATABASE plaintext;")
conn.close()

Bỏ qua PBKDF2 bằng Raw Hex Key

256.000 vòng PBKDF2 tiêu tốn thời gian CPU thực tế đáng kể. Nếu ứng dụng của bạn đã lấy sẵn một khóa 256-bit an toàn từ OS Keychain hoặc Android Keystore, bạn có thể bỏ qua hoàn toàn bước dẫn xuất khóa. Hãy truyền vào chuỗi raw hex gồm 64 ký tự với tiền tố x':

# Cung cấp raw key 32 byte (256-bit) để bỏ qua hoàn toàn PBKDF2
raw_hex = "x'2DD29CA851E3B39E8A29A26A30F2AC8A2DD29CA851E3B39E8A29A26A30F2AC8A'"
cursor.execute(f"PRAGMA key = \"{raw_hex}\";")

Tích hợp Mobile (Android Room)

Tránh tự biên dịch các binary OpenSSL tùy chỉnh trên Android. Hãy sử dụng official Android bindings của Zetetic cùng với Android Jetpack Room:

val passphrase = getPassphraseFromKeystore()
val factory = SupportFactory(SQLiteDatabase.getBytes(passphrase.toCharArray()))

val db = Room.databaseBuilder(context, AppDatabase::class.java, "secure.db")
    .openHelperFactory(factory)
    .build()

Ghi chép thực tế từ 6 tháng chạy Production

1. Cache các kết nối đang mở

Trên các dòng điện thoại Android tầm trung (như Snapdragon 778G), 256.000 vòng PBKDF2 mất khoảng 85 đến 130 mili-giây. Trên Apple M2, con số này giảm xuống chỉ còn khoảng 8 mili-giây. Đừng bao giờ mở và đóng database handle liên tục trong các vòng lặp UI tần suất cao. Hãy duy trì một kết nối singleton xuyên suốt vòng đời của ứng dụng.

2. Tối ưu Page Cache

Sau khi giải mã, các page sẽ tồn tại dưới dạng cleartext trong RAM thuộc bộ nhớ cache của SQLite. Các truy vấn đọc trên các page đã được cache sẽ chạy ở tốc độ nguyên bản của SQLite. Hãy tăng dung lượng cache để tránh việc đọc/ghi đĩa lặp lại và giảm chi phí xử lý mã hóa:

-- Phân bổ khoảng 20MB cho page cache trong bộ nhớ
PRAGMA cache_size = -20000;

3. Xử lý Stream cho dữ liệu Seed số lượng lớn

Quá trình seed dữ liệu offline thường bắt đầu từ các bản dump thô của khách hàng hoặc sản phẩm. Khi chuyển đổi các feed CSV nguồn thành file fixture JSON chuẩn chỉnh, các tiện ích chạy trên trình duyệt theo mô hình zero-knowledge như công cụ CSV to JSON trên toolcraft.app (toolcraft.app/vi/tools/data/csv-to-json) sẽ ngăn chặn việc rò rỉ các bản ghi bảo mật lên các API bên ngoài.

Sau khi chuyển đổi, hãy nạp dữ liệu bằng batched parameter insert bên trong transaction tường minh. Trong các bài thử nghiệm benchmark của chúng tôi, việc bọc 10.000 thao tác insert trong một lệnh BEGIN TRANSACTION duy nhất đã rút ngắn thời gian ghi từ 8,4 giây xuống chỉ còn 240 mili-giây.

4. Rotate khóa với PRAGMA rekey

Việc đổi (rotate) khóa mã hóa không yêu cầu tạo tệp database mới. Hãy chạy lệnh PRAGMA rekey trên một kết nối đã được xác thực:

# Xác thực với khóa hiện tại
cursor.execute("PRAGMA key = 'old-passphrase';")

# Xác nhận ủy quyền thành công bằng cách truy vấn bảng schema
cursor.execute("SELECT count(*) FROM sqlite_master;")

# Mã hóa lại toàn bộ page bằng khóa mới
cursor.execute("PRAGMA rekey = 'new-strong-passphrase';")
conn.commit()

SQLCipher sẽ ghi salt mới vào header của tệp và mã hóa lại các page hiện có ngay tại chỗ. Dữ liệu của bạn luôn an toàn, ngay cả khi tiến trình bị gián đoạn đột ngột ngoài ý muốn.

Share: