Рассмотрим задачу работы с персональными данными в системе, где большая часть данных находится в открытом доступе и не может строго контролироваться. В этом случае популярным решением будет вынесение чувствительных данных в отдельный защищенный контур с контролируемым доступом. Раскрытие данных по имеющимся ключам в требуемой точке является тривиальной задачей, но все усложняется, когда большие объемы конфиденциальных данных требуется фильтровать или использовать для сортировки. Если упростить задачу до сути: нам нужно быстро искать и сортировать конфиденциальные строки минимизируя обращения к закрытой зоне, но при этом не раскрывая их содержимое. Очевидным решением является использование индексов по закрытым данным в открытой зоне. Однако классические варианты либо плохо масштабируются, либо слишком много «сливают» через индекс.
В этом тексте предлагается практический подход к решению этой проблемы на базе ULBT (Unbalanced Lexicographic Bucket Tree). Предложенный подход предполагает решение следующих задач
поиск по подстроке;
префиксный поиск;
пейджинг с сортировкой;
Базовым ограничением будет статичность индекса. Это означает что назначенный индекс фиксирован для уникальной строки.
Зачем вообще это нужно
В системах с разделением зон (доверенная/недоверенная) обычно всё хорошо, пока нужен доступ по первичному ключу. Проблемы начинаются, когда требуются реальные продуктовые операции:
contains(поиск подстроки),startsWith(префикс),order by+pagingпо защищённому полю.
Наивный путь — гонять большие массивы кандидатов в закрытую зону и обрабатывать (фильтровать или сортировать) там. На десятках и сотнях миллионов записей производительность быстро упирается в сеть.
Нужен индекс в открытой зоне. Но индекс всегда что-то раскрывает. Поэтому задача формулируется как инженерный компромисс:
Не терять полноту поиска (без false negatives).
Прогнозируемо резать кандидатов до приемлемого значения до отправки в закрытую зону.
Не требовать переиндексации всего массива при каждой вставке.
Ограничить утечки через структуру индекса.
Модель угроз (что считаем атакой)
Целевая модель в этой реализации:
утечка таблиц и индексов из открытой зоны;
частичная расшифровка отдельных записей через доступные каналы;
активный сценарий: получение ключевого материала для специально подобранных записей.
Цель дизайна — даже в этих условиях не дать дёшево восстановить остальной массив и его точный смысл.
Идея ULBT в одном абзаце
ULBT — это лексикографическое дерево бакетов фиксированной ёмкости N, где при расщеплении узла сохраняется стабильность ссылок для уже опубликованных записей. Во внешнем контуре для операций с закрытыми данными мы оперируем не исходными строками, а идентификаторами бакетов bucket_id.
Ключевое свойство: структура поддерживает селективную фильтрацию и диапазонные операции, не требуя каждый раз переписывать внешний индекс «с нуля».
Как выглядит ULBT (упрощённо)
Представьте дерево, в котором каждый узел может содержать не более N ключей (например, N=5).
При добавлении 6-го ключа узел расщепляется: создаются 6 дочерних узлов, а родитель превращается во внутренний узел с разделителями. При превышении предела дочернего узла он в свою очередь становится внутренним узлом и создает дочерние узлы
Важнейшая деталь: идентификатор узла (bucket_id) никогда не меняется после создания. При расщеплении узел АА остаётся на месте, но превращается из листа во внутренний узел. Все внешние ссылки на него продолжают работать, а закрытая зона знает, как прозрачно перенаправить запрос в нужный дочерний узел.
Что где хранится
Закрытая зона
Словарь для сопоставления уникальных ключей с исходными значениями строк;
структуры ULBT для формирования индексов открытой зоны;
база хранения структур с данными для восстановления после рестарта сервиса.
Открытая зона
индексы для фильтрации и сортировки из ULBT дерева закрытой зоны;
Уникальный ключ записи закрытой зоны;
Важно: во внешней зоне нет исходных строк в открытом виде.
Поиск по подстроке: как это делается
Для поиска в открытой зоне используется ULBT дерево для хранения триграмм. Построение триграммного индекса в закрытой зоне состоит из следующих шагов:
Входящая строка раскладывается на триграммы.
Каждая триграмма получает индекс бакета в дереве ULBT в виде последовательности символов.
Формируем внешний индекс, путем "склеивания" полученных последовательностей в единую строку.
При поиске в открытой зоне поисковая последовательность посылается в закрытую зону и полученный ключ используется для поиска.
Пример: для входящего "азбука" выделяем триграммы и ищем/добавляем их в ULBT. В результате получаем набор индексов " аз"(an) ,"азб"(c), "збу"(cf), "бук"(cd), "ука"(fee). Разная длина индекса определена глубиной в дереве бакета с найденной триграммой. Финальный индекс в открытом контуре будет anccfcdfee. Поисковый запрос по подстроке "збук" в этом случае преобразуется в cfcd, что позволит легко найти нужную строку по индексу.
Обратите внимание: коды склеиваются без разделителей, что делает невозможным однозначное восстановление исходных триграмм по индексу даже в случае его компрометации.
Очевидно, что подобная фильтрация даст некоторое количество ложноположительных значений, так как cfcd может сформироваться и из других индексов (c fcd или cfc d или даже c f c d), но это с точки зрения безопасности скорее преимущество, так как дополнительно усложняет анализ.
Идея простая: мы не доказываем совпадение окончательно, мы быстро получаем узкий список кандидатов для финальной проверки в доверенной зоне.
Префиксный поиск
Префиксный поиск по началу строки/токена очевиден. К поисковой подстроке добавляем лидирующий пробел и ищем совпадения либо по началу индекса для начала строки, либо как подстроку для поиска по началу токена. .
Пейджинг и сортировка по защищённому полю
Для целей пейджинга в закрытой строится отдельный ULBT, где в качестве значений фигурируют строки целиком. Индексы бакетов в этом случае используются как сортирующий индекс в открытой зоне. Этот индекс не позволяет без обработки напрямую. Поэтому для поиска кандидатов нужной страницы в открытой зоне делается обработка. Алгоритм в этом случае следующий
в открытой зоне не основании индекса строится частотное дерево, определяющее фактическое количество записей таблицы или предварительно отфильтрованного набора в бакете с учетом всего поддерева.
По данным skip-take делается отсечение поддеревьев в частотном дереве слева и справа.
Выполняется дофильтрация в глубину дерева пессмимистичным алгоритмом, чтобы избежать ложно-отрицательного отсечения.
Практический эффект: в закрытую зону уходит «страница + небольшой хвост», а не весь массив.
Правила эксплуатации (важнее всего в проде)
Конкурентность
Вставка записей в дерево происходит достаточно быстро, но если этого недостаточно, то можно частично снять ограничение на параллельную вставку. Чувствительной может быть ситуация, когда при вставке в лист происходит очередное деление бакета. В зависимости от порядка вставки записи получат разные индексы. Так как нелистовые узлы статичны, достаточно блокировки параллельной вставки на уровне листа
модель блокировок: lock per leaf;
независимые вставки в разные листья идут параллельно.
Удаление
Значения в узлах используются для определения границ нижележащих бакетов. Особенно это критично для бакетов, имеющих непосредственные листовые узлы. С точки зрения идеологии закрытой зоны удаление данных в ней и так нежелательно. Поэтому можно принять следующие правила
Произвольное удаление запрещено;
разрешён только последовательный откат последних записей (LIFO-rollback).
LIFO-rollback означает, что можно безопасно удалить только последнюю добавленную транзакцию, если она ещё не была зафиксирована во внешней зоне.
Обновление
UPDATEреализуется как новая версия значения;старые индексы не переписываются.
Консистентность публикации
данные и индекс публикуются наружу только после успешного
COMMITв закрытой зоне;если сбой до
COMMIT— наружу не выходит ни ключ, ни индекс.
Инварианты реализации
Инвариант |
Что это означает на практике |
|---|---|
Единый идентификатор |
Во внешней зоне везде используется |
Фиксируемость ссылок |
Для уже опубликованных записей |
Режим удаления |
Только LIFO-rollback, без произвольных удалений |
Атомарная публикация |
Внешний контур видит запись только после |
Восстановление |
Дерево поднимается из индексов в БД закрытой зоны |
Модель обновлений |
Новая версия создаёт новый индекс, старый не трогаем |
? Почему не Bloom Filter и не OPE?
Метод |
Плюсы |
Минусы и почему не подходит |
|---|---|---|
Bloom Filter |
Очень компактный, быстрый |
Не поддерживает префиксный поиск; требует знания хеш-функций на клиенте; возможна атака восстановления при известных хешах. Bloom Filter не поддерживает позиционные условия, а ULBT за счёт хранения позиций триграмм резко снижает false positive rate. |
Order-Preserving Encryption (OPE) |
Сортировка «из коробки», быстро |
Критическая утечка: раскрывает точный порядок всех записей, уязвим для инференционных атак и частотного анализа |
ULBT |
Минимальная утечка, фиксируемость, поддержка префиксов и подстрок |
Сложнее в реализации, двухэтапный поиск, небольшое превышение кандидатов |
Что получилось в экспериментах
Описанный алгоритм был реализован и обкатан на стенде
Стенд:
Файл с 1,8 млн строк, эмулирующих закрытые данные;
закрытая зона в виде WebAPI-сервиса.
открытая зона в виде консольного клиента на C# (.NET 9);
таблица 200 000 записей (подмассив ключей и индексов данных с дублирующимися ключами из закрытой зоны). Для проверки в таблице храним отдельный столбец с закрытыми данными, который используем для проверки правильности работы индексов.
Наблюдения:
построение строчного ULBT 1,8 млн строк (
N=26) — около 9 секунд;глубина дерева при построениях для 100 прогонов с перемешиванием порядка ввода близка к теоретическому минимуму (не более 6 уровней при оптимальных 5);
При тестировании пейджинга на 100 рандомных страницах рандомного размера в закрытую зону уходило размер страницы + 5…30 записей (например, для страницы 50 — от 55 до 80 кандидатов) Ложно-отрицательных результатов не наблюдалось. После обработки в закрытой зоне результат страницы в точности соответствовал контрольному набору открытых данных;
поиск по открытому индексу: 300–900 мкс для пейджинга, 1–5 мс для подстрочного поиска.
Где границы применимости
ULBT хорошо подходит, когда:
записи не удаляются;
нужны практичные
contains/prefix/sort+page;критично не перерасчитывать весь индекс при вставках;
есть строгое разделение доверенной и недоверенной зон.
ULBT не серебряная пуля, если:
нужна свободная модель удаления/перестроения;
требуется формально доказанная защита leakage-профиля уровня академических SSE-схем;
недопустимы даже структурные утечки порядка.
Вывод
ULBT — компромисс с чёткими ограничениями. В практических сценариях с разделением зон он даёт то, чего обычно не хватает:
приемлемую безопасность,
высокую селективность,
предсказуемую эксплуатацию,
и скорость, достаточную для реальной продуктовой нагрузки.
А какие подходы к поиску по зашифрованным данным используете вы? Сталкивались ли с ограничениями OPE или SSE-схем в продакшене? Делитесь опытом в комментариях.