Перейти к содержанию

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).

Ссылки