Mã hoá trên máy trong một app két React Native, không có reset mật khẩu
Cách Lockboxy mã hoá trên iPhone: khoá Argon2id từ passcode, khoá dữ liệu được bọc, AES-256-GCM theo chunk, Keychain, backup và những giới hạn thật.

Trong bài này
Lockboxy là app két riêng tư cho iPhone, viết bằng React Native. Phần mô tả trên App Store hứa ba điều: mã hoá AES-256-GCM ngay trên máy, khoá được dẫn xuất từ passcode bằng Argon2id và không bao giờ được lưu, và không có chức năng reset mật khẩu. Điều nào cũng là một quyết định thiết kế có cái giá của nó. Bài này đi qua cách chúng ghép lại với nhau trong code.
Mình giữ ở mức thiết kế: cái gì nằm ở đâu, tại sao, và những giới hạn mình biết. Đây không phải hướng dẫn tấn công thứ gì cả, và cũng không phải lời khẳng định app không thể bị phá.
Mã hoá để chống ai
Threat model trong repo bắt đầu từ một câu hỏi rất đời thường: ảnh không rời khỏi máy thì mã hoá làm gì? Câu trả lời là cơ chế bảo vệ file của iOS được thiết kế cho lúc máy đang khoá hoặc đã tắt. Còn một app két riêng tư chủ yếu có ý nghĩa khi máy đã mở khoá: người nhà mượn máy, máy mang đi sửa, hay ai đó bắt bạn mở máy. Qua khỏi màn hình khoá rồi thì file của app nằm phơi ra trước backup của máy, các app duyệt file và công cụ trích xuất toàn bộ filesystem.
Vậy đối tượng trong phạm vi là người đang cầm máy đã mở khoá, có backup của máy, hoặc có bản sao filesystem, nhưng không biết passcode của két. Ngoài phạm vi, và được ghi rõ như vậy: người đã biết passcode, malware chạy với quyền của chính app khi két đang mở, và việc quay hay chụp màn hình lúc nội dung đang hiển thị.
Cây khoá
Có ba tầng khoá, và chỉ tầng dưới cùng mới đụng tới nội dung.

- Passcode → master key. Passcode 4 số đi qua Argon2id với salt ngẫu nhiên 16 byte và ra một master key 32 byte. Master key không bao giờ được lưu. Nó được tách bằng HKDF-SHA256 thành các subkey, mỗi cái có nhãn ngữ cảnh riêng, rồi bị xoá trắng.
- Master key → DEK. Mỗi két có một data encryption key (DEK) 256-bit ngẫu nhiên. DEK chỉ được lưu ở dạng đã bọc, niêm bằng AES-256-GCM dưới một subkey từ bước 1. Phần AAD gắn bản bọc với đúng két và đúng đường mở của nó.
- DEK → nội dung. File được mã hoá bằng DEK. Database SQLCipher chứa metadata dùng một khoá dẫn xuất từ DEK.
Tham số Argon2id cho két mới là hằng số trong module crypto:
export const ARGON2ID_PARAMS: KdfParams = Object.freeze({
v: 1,
m: 65536, // KiB, tức 64 MiB bộ nhớ cho mỗi lần thử
t: 3, // số vòng lặp
p: 1, // độ song song
});Két đã tồn tại luôn đọc lại tham số đã lưu chứ không lấy từ hằng số này, nên sau này có thể tăng mặc định mà không khoá ai ở ngoài. Comment trong code cũng nói thẳng các giá trị này là ước lượng, chưa phải kết quả benchmark.
Lý do có tầng DEK là để đổi passcode chỉ phải bọc lại một khoá 32 byte, không phải mã hoá lại từng tấm ảnh. Cách chia tầng này còn bắt được một bug thật. Khoá database từng được dẫn xuất từ master key, mà master key thì đổi theo passcode. Vậy là mỗi lần đổi passcode, khoá mà file database cần cũng âm thầm đổi theo, trong khi file chưa hề được rekey. Chuyển sang dẫn xuất khoá database từ DEK, vốn không đổi, là hết lỗi. Một migration chạy một lần sẽ chuyển các bản cài cũ sang khoá mới.
Mã hoá một file

Nội dung đi qua react-native-quick-crypto, mã hoá AES-256-GCM dạng stream theo từng chunk 1 MiB. Mỗi chunk có nonce riêng, ghép từ một prefix ngẫu nhiên của từng file và số thứ tự chunk, cùng auth tag riêng. ID của item, số thứ tự chunk và cờ "đây là chunk cuối" được gắn vào làm dữ liệu xác thực. Nhờ vậy, một chunk bị chuyển sang file khác, bị đảo thứ tự, hay một file bị cắt cụt đều giải mã thất bại. Không trường hợp nào âm thầm trả về dữ liệu sai.
Có hai luật ít ai để ý nhưng giữ cho chuyện này an toàn:
- Ghi vào đường dẫn tạm rồi mới đổi tên. Nếu ghi dở mà lại ghi tiếp, cùng một nonce có thể bị dùng để mã hoá hai dữ liệu khác nhau, và dùng lại nonce là phá GCM. Vì vậy blob ghi dở luôn bị bỏ, không bao giờ ghi tiếp.
- Bản rõ chỉ nằm trong file tạm để xem, và file đó bị xoá khi đóng, kể cả khi gặp lỗi.
Blob trên đĩa có tên mờ đuôi .bin, nên không có gì trong container tự lộ ra là ảnh hay video.
Cái gì nằm ở đâu
| Nơi | Chứa gì |
|---|---|
| Keychain, mỗi két một item | salt, tham số KDF, DEK đã bọc (và một bản bọc riêng cho Recovery Key) |
| Container của app | blob đã mã hoá, database SQLCipher |
| RAM, khi đang mở khoá | DEK và các khoá dẫn xuất, giữ kín bên trong crypto service |
| Không ở đâu cả | passcode và master key |
Các item Keychain dùng WHEN_UNLOCKED_THIS_DEVICE_ONLY và không bao giờ vào iCloud Keychain. Mở khoá bằng Face ID là một item riêng, gắn với BIOMETRY_CURRENT_SET. Khi quét thành công, nó đưa passcode vào đúng đường mở khoá như lúc gõ tay, cùng cơ chế throttle. Đăng ký thêm khuôn mặt hay vân tay mới sẽ vô hiệu item đó, và mọi lần đổi passcode cũng vậy.

Backup
Vì item Keychain chỉ thuộc về máy này, một bản backup mã hoá của máy restore sang iPhone mới sẽ mang theo ciphertext nhưng không mang theo bản ghi khoá. Nên Lockboxy có file backup riêng. File này gói bản ghi khoá, file database và mọi blob đúng như chúng đang nằm trên đĩa. DEK giữ nguyên, không mã hoá lại gì cả. File đi qua share sheet của iOS tới bất cứ đâu người dùng chọn, iCloud Drive, Google Drive hay AirDrop. Không có server nào của mình ở giữa.
Restore cần passcode gốc hoặc Recovery Key. Ngoài ra còn bước "Verify backup" để chứng minh một file giải mã được mà không đụng tới két đang dùng. Một bản backup chưa từng thử restore thì mới chỉ là hy vọng.
Tại sao không có reset mật khẩu
Muốn reset thì phải có bản sao thứ hai của khoá ở đâu đó: trên server, trong một tài khoản, hay sau một câu hỏi bảo mật. Lockboxy không có tài khoản, không có server, và khoá được dẫn xuất từ một passcode mà app không hề lưu. Nếu mình reset được passcode của bạn thì mình đọc được két của bạn, và bất kỳ ai nắm được thứ cho phép reset đó cũng đọc được.
Màn hình cài đặt nói rõ điều này trước khi bạn chọn passcode. Đường lui duy nhất là Recovery Key, và nó là tuỳ chọn:
- Đó là một secret ngẫu nhiên 256-bit, hiện đúng một lần dưới dạng 80 chữ số để gõ được trên bàn phím số.
- Nó bọc cùng một DEK một cách độc lập, trong slot Keychain riêng.
- Nó đi qua HKDF nhanh thay vì Argon2id. Một secret ngẫu nhiên 256-bit chẳng có gì dễ đoán để Argon2id phải làm chậm.
- Trước khi key được hiển thị, toàn bộ pipeline được kiểm tra khứ hồi (encode, decode, derive, wrap, lưu, đọc lại, unwrap, so sánh).
- Thiết lập Recovery Key là tính năng Premium, nhưng dùng nó thì không bao giờ cần Premium, để người hết hạn subscription không bị khoá ngoài.
Những giới hạn mình muốn nói thẳng
- Passcode 4 số là một không gian nhỏ. Argon2id làm mỗi lần đoán tốn bộ nhớ và thời gian, app còn tăng dần thời gian chờ sau mỗi lần nhập sai. Nhưng không cách nào biến 10.000 tổ hợp thành con số lớn. Bản sao của bản ghi khoá hay file backup chỉ mạnh bằng passcode đó.
- Bản ghi trong Keychain không gắn với sinh trắc học hay Secure Enclave. Nó được bảo vệ bằng lớp Keychain chỉ-máy-này, và code đánh dấu đây là một lỗ hổng còn mở, chưa giải quyết.
- String trong JavaScript không xoá trắng được. Buffer chứa khoá được xoá sau khi dùng, nhưng passcode vẫn đi qua một string bất biến trong JS engine.
- Không gì cứu được khi người khác đã biết passcode, hoặc khi malware chạy bên trong app đang mở khoá.
Đằng sau tất cả là một nguyên tắc: không tự chế primitive. Argon2id, AES-GCM, HKDF và SQLCipher đều lấy từ các thư viện đã được dùng rộng rãi. Phần mình tự làm là cách ghép chúng lại, và các bug kể trên đều nằm ở đó.
Rút ra
Lưu khoá đã bọc, đừng lưu khoá dẫn xuất. Gắn ngữ cảnh vào mọi lần gọi AEAD. Ghi file theo kiểu atomic. Coi "không có reset" là một lời hứa phải giải thích ngay lúc cài đặt. Phần App Store của app này nằm ở bài Đưa một app két riêng tư qua App Review, còn các app khác của mình ở apps.vanthuongdao.id.vn.
Bài liên quan


