Đồng bộ dữ liệu giữa các máy mà máy chủ không đọc được: ghi chép làm mã hóa đầu cuối cho app React Native
Mình đang làm Bé Xíu, một app nhật ký chăm em bé (cữ bú, giấc ngủ, tã, lịch tiêm, cân nặng chiều cao) viết bằng Expo, dữ liệu nằm trong SQLite trên máy. Đến lúc làm tính năng "cả nhà cùng ghi" (mẹ ghi trên máy mẹ, ba và bà thấy ngay trên máy mình), mình đặt ra một yêu cầu khó: máy chủ chỉ trung chuyển, không đọc được nhật ký của bé.
Lý do không chỉ là "cho an toàn". Theo Luật Bảo vệ dữ liệu cá nhân 2025, tình trạng sức khỏe là dữ liệu cá nhân nhạy cảm, và dữ liệu đã mã hóa vẫn là dữ liệu cá nhân. Nên mình muốn giảm tối đa những gì máy chủ biết, và đặt máy chủ ở Việt Nam để khỏi phải làm thêm bộ hồ sơ chuyển dữ liệu ra nước ngoài.
Bài này ghi lại cách mình làm, những chỗ đã sai và sửa. Cuối bài có một lời nhờ nhỏ 🙏
1. Vì sao không dùng dịch vụ có sẵn
Mình có xét vài hướng:
| Hướng | Vấn đề với app này |
|---|---|
| Supabase, Firebase | Vẫn phải tự viết phần mã hóa và đồng bộ, lại tốn phí hằng tháng từ đầu, máy chủ ở nước ngoài |
| PowerSync, ElectricSQL, Turso | Không có mã hóa đầu cuối sẵn, hoặc nhà cung cấp giữ khóa |
| Thư viện local-first có mã hóa sẵn | Phải bỏ lớp dữ liệu SQLite + Drizzle đã viết và đã có test, API còn thay đổi, chưa lo phần ảnh |
Cuối cùng mình tự viết một máy chủ trung chuyển rất mỏng bằng TypeScript (Hono), chạy trên VPS ở Việt Nam, ảnh mã hóa để trong kho S3 cũng ở Việt Nam.
2. Kiến trúc: máy chủ chỉ làm 3 việc
Máy mẹ (SQLite) Máy chủ trung chuyển Máy ba, máy bà (SQLite)
ghi dòng mới ──mã hóa──▶ nhận gói, gắn số thứ tự ──▶ tải gói mới ──giải mã──▶ ghi vào SQLite
ảnh ──mã hóa từng file──▶ kho S3 (chỉ bản mã hóa) ──▶ tải khi cần xem
Máy chủ chỉ:
- nhận gói đã mã hóa, gắn số thứ tự tăng dần theo từng gia đình;
- trả các gói có số thứ tự lớn hơn số máy đó đã nhận;
- cấp link có hạn để tải ảnh mã hóa lên và xuống.
Trên máy có bảng sync_outbox: mỗi lần thêm, sửa, xóa mềm một dòng thì nguyên dòng được đưa vào hàng chờ, có mạng thì gửi. Máy chủ sập thì app vẫn ghi bình thường, có mạng lại thì gửi tiếp. Đây là app offline-first nên điều này quan trọng hơn mọi thứ.
3. Giải quyết xung đột ngay trên máy
Vì máy chủ không đọc được gói, việc gộp phải làm trên máy sau khi giải mã:
- Mỗi dòng có
idUUID,updated_at,deleted_attừ ngày đầu (thiết kế sẵn cho việc này). - Dòng nào
updated_atmới hơn thì thắng. Bằng nhau thì so chuỗi JSON đã chuẩn hóa của dòng, để mọi máy đều chọn giống nhau. Thiếu bước này thì hai máy có thể giữ hai bản khác nhau mãi mãi. - Xóa là xóa mềm, tức là một lần sửa, nên xóa cũng đồng bộ được.
- Cữ bú mẹ đổi bên giữa chừng được lưu thành nhiều dòng chung
group_id, nên mẹ và ba cùng ghi cũng không đè lên nhau.
4. Mã hóa
- Khóa gia đình 32 byte ngẫu nhiên, tạo trên máy đầu tiên, cất trong Keychain/Keystore qua
expo-secure-store. Khóa không bao giờ lên máy chủ ở dạng đọc được. - Mỗi dòng mã hóa bằng XChaCha20-Poly1305. Máy chủ không thấy cả tên bảng lẫn mã dòng thật: mỗi dòng gửi lên chỉ có một mã che (HMAC-SHA256 của tên bảng và mã dòng, bằng một khóa con rút từ khóa gia đình) và bản mã. Mã che được gắn vào bản mã làm dữ liệu kèm (AAD), bên trong lại kiểm mã che có khớp bảng và mã dòng không. Máy chủ tráo gói của dòng này sang dòng khác thì giải mã báo lỗi ngay.
- Mã che và mã hóa dòng dùng hai khóa con khác nhau rút bằng HKDF, không dùng chung một khóa cho hai việc.
- Mỗi ảnh một khóa riêng, rút từ khóa gia đình bằng HKDF-SHA256:
// Khóa riêng của một ảnh: HKDF-SHA256 từ khóa gia đình
export function photoKey(key: Uint8Array, photoId: string) {
return hkdf(sha256, key, undefined, utf8(`bexiu/photo/v1/${photoId}`), 32);
}
- Thư viện:
@noble/ciphers,@noble/hashes,@noble/curves. Mình chọn JavaScript thuần thay vì bản gốc của libsodium vì chạy y hệt trên iPhone, Android và trong Jest, không phải build mã gốc. Đổi lại phải tự đối chiếu: mình chạy số mẫu của libsodium, IETF, RFC 5869 (HKDF), RFC 7748 (X25519) trong test để chắc kết quả trùng từng byte. - Mỗi máy có thêm một cặp khóa Ed25519 để ký yêu cầu, máy chủ nhận ra đúng máy trong gia đình mà không cần email, mật khẩu hay số điện thoại.
5. Thêm máy mới: QR + 6 chữ số
Phần khó nhất là đưa khóa gia đình sang máy mới mà máy chủ (đứng giữa) không lấy được:
- Máy mới tạo cặp khóa X25519 tạm, hiện QR chứa khóa công khai (hết hạn sau 10 phút).
- Máy cũ quét QR bằng camera, tính bí mật chung.
- Cả hai máy hiện 6 chữ số để người dùng so cho khớp. 6 số này rút từ bí mật chung, không phải từ khóa công khai:
const shared = x25519.getSharedSecret(mySecret, theirPublic);
const key = hkdf(sha256, shared, salt, utf8('bexiu/pair/key/v1'), 32);
const d = hkdf(sha256, shared, salt, utf8('bexiu/pair/digits/v1'), 4);
// 6 số hiện trên cả hai máy
- Chỉ khi người dùng xác nhận 6 số khớp trên cả hai máy, máy cũ mới gói khóa gia đình gửi đi.
Lúc đầu mình chỉ bắt xác nhận trên một máy. Soát lại mới thấy: ai tráo được QR mà máy cũ quét thì 6 số trên máy thật sẽ khác, nên phải bắt so trên cả hai máy. QR cũng không chứa khóa gia đình, lỡ bị chụp màn hình cũng không sao.
6. Mã khôi phục: 28 ký tự thay vì 24 từ tiếng Anh
Mất hết máy thì cần một thứ để lấy lại. Mình bỏ kiểu 24 từ tiếng Anh (khó với ba mẹ Việt), dùng 28 ký tự chia 7 nhóm 4, ví dụ 7KQ4-M2XD-9PF8-HT3W-R6BN-4JZC-VA5E:
- 128 bit, chỉ chữ in và số, bỏ các ký tự dễ nhầm.
- Có ký tự kiểm lỗi, chép sai 1 ký tự hay đảo 2 ký tự là báo ngay.
- Mã khôi phục mở bản gói của khóa gia đình, không phải chính khóa. Gỡ một máy thì đổi khóa, gói lại và đưa mã mới.
- Mất cả máy lẫn mã thì không ai lấy lại được, kể cả mình. App nói thẳng điều này.
7. Mấy lỗi ngoài đời không có trong sách
Bàn phím tiếng Việt nuốt chữ khi gõ mã. Lúc thử trên máy thật, gõ mã khôi phục bằng bàn phím Telex thì "9EPR" thành "9ẺP": chữ R bị hiểu là dấu hỏi, mất luôn một ký tự, mã sai mà người dùng không hiểu vì sao. Sửa bằng cách đổi loại bàn phím cho ô nhập mã:
// Ô gõ mã khôi phục: không gõ dấu tiếng Việt
export function codeKeyboard(os: string): KeyboardTypeOptions {
if (os === 'android') return 'visible-password'; // tắt bộ gõ dấu, gợi ý
if (os === 'ios') return 'ascii-capable'; // chỉ bàn phím chữ Latin không dấu
return 'default';
}
Thêm quét QR làm mất 435 máy trên Google Play. Thêm thư viện camera thì manifest khai camera là bắt buộc, Google Play lặng lẽ ẩn app với 69 điện thoại và 287 máy tính bảng không có camera. Sửa bằng một config plugin nhỏ đặt android:required="false" cho camera và lấy nét tự động. Bài học: sau mỗi lần thêm quyền, mở mục "Thay đổi về thiết bị được hỗ trợ" ở bước xem trước bản phát hành mà đọc.
8. Lời nhờ nhỏ 🙏
Bản iPhone đã lên App Store. Bản Android thì đang kẹt luật của Google: tài khoản cá nhân phải có ít nhất 12 người thử kín liên tục 14 ngày mới được lên CH Play. Mình cần khoảng 15 đến 20 bạn có máy Android:
- Điền email Gmail đang đăng nhập CH Play vào form (1 phút): https://forms.gle/S8zXeP7hGWUWYo1V6
- Sau khi mình thêm vào danh sách, bấm link tham gia và cài từ CH Play: https://play.google.com/apps/testing/vn.bexiu.app
- Giữ app đủ 14 ngày, tuần mở vài lần. Muốn thử luôn phần đồng bộ ở trên thì vào Cài đặt, Đồng bộ gia đình (giai đoạn này mở free), ghép thử 2 máy và soi giúp mình chỗ nào chưa ổn.
App free, không quảng cáo, không cần tài khoản. Email trong form chỉ dùng để thêm vào danh sách thử trên Google Play. Góp ý kỹ thuật thì cứ comment dưới bài, mình đọc hết.
Cảm ơn các bạn đã đọc tới đây!
All rights reserved