ADR 0005: Шифрование PII¶
Дата: 2026-06-06 Статус: Accepted
На какие вопросы отвечает¶
- Где и как хранятся персональные данные пользователя (телефон, ФИО, номер удостоверения, адрес)?
- Почему выбран AES-256-GCM, а для поиска — HMAC с pepper, а не обычный хэш?
- Как искать пользователя по телефону/natId, если данные зашифрованы?
- Кто и когда может расшифровать PII в открытый вид?
- Что видит оператор admin-panel «по умолчанию» (без расшифровки)?
- Где лежат ключи шифрования и как они подаются в сервис?
Контекст¶
OneWallet — e-money кошелёк под надзором Bank of Thailand (BOT). Профиль
содержит чувствительные PII: телефон, имя/фамилия, дата рождения, номер
национального удостоверения, адрес и т.п. (public.user_profile, схема
Serverpod). Хранить их открытым текстом в БД недопустимо: компрометация дампа
БД = утечка всех PII; это нарушает требования BOT к защите данных клиентов.
Одновременно нужны два конфликтующих свойства: - конфиденциальность at-rest — никто, имея доступ только к БД, не должен прочитать данные; - поиск по точному совпадению — найти пользователя по телефону или natId, обеспечить уникальность (один телефон = один аккаунт).
Прямой индекс по зашифрованному столбцу невозможен (GCM рандомизирован nonce), а простой хэш (sha256) уязвим к rainbow/brute-force на малом пространстве (номера телефонов и 13-значные natId перебираются полностью).
Решение¶
Разделяем данные на три представления, каждое со своим механизмом:
| Что | Механизм | Где хранится |
|---|---|---|
| Полный PII-JSON | AES-256-GCM | user_profile.encryptedPii (ByteData) |
| Ключ поиска phone / natId | HMAC-SHA256 + pepper | user_profile.phone, .nationalIdNumber |
| Отображение оператору | Маскирование | .phoneMasked, .nationalIdMasked, firstName/lastName |
1. AES-256-GCM (PiiCryptoService, пакет cryptography). Весь PII
сериализуется в JSON и шифруется в blob:
[1 байт версия=0x01][12 байт nonce][ciphertext + 16 байт GCM-tag]. GCM —
аутентифицированное шифрование: подмена байтов в БД детектируется при
расшифровке (целостность), а 96-битный nonce генерируется на каждую запись.
2. HMAC-SHA256 с pepper для ключей поиска (PiiHashService). Телефон
нормализуется в E.164 (phone_numbers_parser), natId — в digits-only, затем
HMAC-SHA256(pepper, value) → hex. Pepper (секретный ключ HMAC, не в БД)
делает offline-перебор бесполезным: без pepper нельзя сопоставить
хэш с номером. Детерминирован → можно навесить unique-индекс и искать точным
совпадением. Отдельные pepper для phone и natId.
Важно: столбцы
user_profile.phoneи.nationalIdNumberхранят HMAC-хэш, а не открытое значение. Открытый телефон лежит только внутриencryptedPii.
3. Маскирование (PiiMaskService) — то, что оператор видит без расшифровки:
телефон → ***5678, natId → *********0123, имя → S*** J***
(культурно-агностично: инициал каждого слова, чтобы не раскрывать фамилию в
family-first нотациях). При encryptPii=true имя/фамилия в firstName/
lastName тоже хранятся в маскированном виде.
4. Расшифровка только по запросу (PiiDecryptionService) — единственная
точка получения открытого PII. decryptForOwner (сам пользователь — без
аудита) и decryptForAdmin (оператор). Авторизация (роль, reason) и запись
в audit_log лежат на слое endpoint, не внутри сервиса. То есть открытый PII
не «течёт» по системе — он восстанавливается на конкретный запрос и логируется.
flowchart TD
IN[Регистрация / KYC] --> W[PiiProfileWriter]
W -->|AES-256-GCM| BLOB[(encryptedPii)]
W -->|HMAC+pepper| H[(phone / nationalIdNumber = хэш)]
W -->|mask| M[(phoneMasked / firstName...)]
OP[Оператор admin-panel] -->|обычный просмотр| M
OP -->|запрос + reason| EP[endpoint: auth + audit_log]
EP --> DEC[PiiDecryptionService] -->|decryptJson| BLOB
Ключи в passwords.yaml¶
Все секреты берутся из Serverpod passwords (config/passwords.yaml, секция
shared/режим, либо env SERVERPOD_PASSWORD_*). Отсутствие ключа → сервис
падает (fail-closed, не пишет открытым текстом):
| Ключ | Назначение |
|---|---|
piiEncryptionKey |
32 байта (base64) — ключ AES-256-GCM |
piiPhoneHashPepper |
pepper для HMAC телефона |
piiNatIdHashPepper |
pepper для HMAC natId |
Запись только через PiiProfileWriter.upsert — единственная точка записи PII;
прямой insertRow/updateRow PII-полей запрещён.
Пример¶
Пользователь регистрирует телефон +66 81 234 5678, natId 1234567890123:
encryptedPii = AES-256-GCM(piiEncryptionKey, {"phone":"+66812345678","firstName":"Somchai",...})
phone = HMAC-SHA256(piiPhoneHashPepper, "+66812345678") // hex, в индексе
nationalIdNumber= HMAC-SHA256(piiNatIdHashPepper, "1234567890123") // hex, в индексе
phoneMasked = "***5678"
firstName = "S***" (encryptPii=true)
Поиск «есть ли аккаунт на этом телефоне»: вычисляем тот же HMAC и ищем точное
совпадение по phone. Оператор в списке видит ***5678 / S***; чтобы
увидеть полный номер — отдельный запрос с reason, который пишется в
audit_log.
Последствия¶
- Комплаенс BOT: дамп БД бесполезен без ключей — PII зашифрованы, ключи
поиска необратимы без pepper. Каждый факт раскрытия PII оператором
фиксируется в
audit_log(кто, когда, зачем) — аудируемость доступа. - Ключи и pepper становятся критическими секретами: их компрометация = риск
расшифровки/перебора. Хранение вне БД (passwords.yaml/env), ротация —
операционная обязанность; ротация
piiEncryptionKeyтребует поддержки версий blob (заложен байт версии0x01). - Поиск возможен только по точному совпадению нормализованного значения; частичный/LIKE-поиск по телефону или natId невозможен by design.
- Любое новое PII-поле должно идти в
encryptedPii-JSON, а не отдельным открытым столбцом — иначе обходится вся модель. - PM (схема
pm.*) не хранит и не расшифровывает PII; он оперирует TB-аккаунтами поname, не персональными данными (см. ADR 0003).
Ссылки¶
- Реализация:
projects/onewallet_base/onewallet_base_server/lib/src/services/pii/(pii_crypto_service.dart,pii_hash_service.dart,pii_mask_service.dart,pii_decryption_service.dart,pii_profile_writer.dart) - Модель:
projects/onewallet_base/onewallet_base_server/lib/src/models/user_profile.spy.yaml - Dev-документация: ../dev/05-security-and-auth.md
- Комплаенс: ../compliance/bot-qa.md
- Смежные ADR: 0002 единый HMAC, 0003 разделение схем