
Всем привет, я Вячеслав Жуйко – Lead команды разработки Audiogram в MTS AI.
При переходе от On-Cloud размещений ПО на On-Premises в большинстве случае перед вами неизбежно встанет задача защиты интеллектуальной собственности – и она особенно критична для рынка AI, где задействуются модели, обладающие высокой ценностью для компании. К тому же, в этой сфере широко используется интерпретируемый язык Python, ПО на котором содержит алгоритмы, являющиеся интеллектуальной собственностью компании, но фактически распространяется в виде исходных кодов. Это не является проблемой для On-Cloud решений, но в случае с On-Premises требует особой защиты как от утечек кода, так и самих данных.
Рассказываю реальную историю решения этой, казалось бы, не самой тривиальной задачи.
Почему нам потребовалось шифровать код и данные
Мы с коллегами разрабатываем Audiogram — платформу синтеза и распознавания речи. Она состоит из большого количества микросервисов, связанных между собой. Обычно мы разворачивали это решение On-Cloud, и поэтому задачи защитить код у нас не возникало. Однако все изменилось, после того как к нам пришел заказчик, которому нужно было установить Audiogram On-Premises. Мы не могли передать код программы клиенту — это создало бы опасность кражи нашей интеллектуальной собственности. Именно поэтому мы начали искать способ зашифровать информацию и остановились на одном простом и эффективном варианте. Далее я расскажу подробнее, как сгенерировать и зашифровать код. Итак, разберем все по шагам.
Генерируем из кода на Python код на C++
Для простоты представим, что у нас уже есть минимальный проект на Python, который загружает файл с данными, а затем использует их. Пусть это будет совсем просто:
with open(path, 'rb') as f: data = f.read() use_data(data)
Понятно, что мы не можем передавать заказчику ни исходный код, ни тем более данные. Последние предстоит зашифровать, а код обфусцировать.
В случае с кодом я использовал Cython – для генерации из кода на Python кода на C или C++. Вообще способы генерации могут быть разными, а самым распространенным является Setuptools. Однако сходу у меня не вышло написать setup.py для генерации исполняемого файла (не библиотеки), поэтому пошел путем использования Cython через коммандную строку.
Примеры вызовов Cython:
cython -3 --no-docstrings --fast-fail --output-file lib.c lib.py cython -3 --no-docstrings --fast-fail --cplus --output-file lib.cpp lib.py cython -3 --no-docstrings --fast-fail --embed --output-file app.c app.py
Рассмотрим используемые параметры:
-3 -- версия Python
--no-docstrings -- не включает Python Docstrings в сгенерированный файл
--fast-fail -- процесс генерации прерывается по первой ошибке
--embed -- включает функцию main(), что позволяет собрать как исполняемый файл и запускать не через интерпретатор, а напрямую: ./app
--cplus -- генерирует C++ вместо C
Теперь полученные C или C++ файлы нужно собрать. Безусловно, применить можно разные подходы, в том числе написать Makefile. Я пошел схожим путем, как и в случае с Cython, и вызвал сборку из командной строки. Если попробуете повторить, сначала убедитесь, что у вас установлены пакеты build-essential python-dev-is-python3.
Примеры вызова сборки из коммандной строки:
gcc $(python3.8-config --cflags) -fPIC -g0 -s -shared lib.c -o lib.so gcc $(python3.8-config --cflags) -fPIC -g0 -s app.c -o app $(python3.8-config --libs --embed)
Рассмотрим используемые параметры:
python3.8-config --cflags -- возвращает CFLAGS
-fPIC -- генерировать позиционно независимый код
-g0 -- не включать информацию для отладки
-s -- удаляет таблицу символов и информацию о релокации
-shared -- собирает динамическую библиотеку
python3.8-config --libs --embed -- возвращает строку с библиотеками для линкера
Параметры -g0 и -s я добавил для затруднения отладки.
Написал скрипт на Python, который проходится по дереву исходников и выполняет две вышеописанные операции: ситонизирует и собирает, после чего удаляет исходник.
Итак, с кодом разобрались – мы больше не поставляем исходники, заменив их на бинарные ELF файлы без Python Docstrings и отладочной информации. Кстати, приятный бонус – ситонизация может увеличить скорость работы по сравнению с Python, особенно если используется type hinting.
Все ли всегда так гладко? Увы, нет. Cython отстает по фичам от Python, например, не поддерживаются "the walrus operator", Data Classes, а функции из inspect возвращают неадекватные результаты – это только то, с чем столкнулись мы. Это все неприятно, но жить с этим можно. К тому же, где-то с проблемой можно справиться, например, в случае с Data Classes достаточно добавить annotations вручную, после чего их можно использовать.
Шифруем данные
Теперь нам осталось зашифровать данные. Для шифрования используем SDK от одного из решений для HASP, в нашем случае это Sentinel.
Средства шифрования файлов в SDK предоставляются "из коробки". Данные зашифрованы, теперь предстоит научить наш код на Python их расшифровывать. Сделать это можно, просто добавить вызов:
with open(path, 'rb') as f: data = f.read() data = decrypt(data) use_data(data)
Правда, сосчитав количество вариантов загрузки данных из файла в реальном коде, я решил все же пойти другим путем. В самых общих словах – где-то данные загружаются как в приведенном примере, где-то построчно, а самое страшное: f передается как параметр в сторонний пакет, в который не хотелось бы погружаться вообще.
В итоге, показалось проще сделать на основе Sentinel SDK статическую библиотеку на C++ libsentinel.a с единственной экспортируемой функцией:
std::vector<unsigned char> sentinel_decrypt(const std::string& path);
Почему именно статическую? Если в файловой системе будет лежать библиотека libsentinel.so с экспортируемой функцией sentinel_decrypt(), воспользоваться ей сможет любой.
Написал на Cython подмену стандартного механизма чтения файла:
import os import io from typing import Union, List, AnyStr, Iterator from libcpp.vector cimport vector from libcpp.string cimport string cdef extern from "sentinel.h": vector[unsigned char] sentinel_decrypt(const string& path) except + def sentinel_open(path: Union[str, bytes, os.PathLike], mode: str, **kwargs) -> 'SentinelFileIo': return SentinelFileIo(path, mode, **kwargs) class SentinelFileIo: def init(self, path: Union[str, bytes, os.PathLike], mode: str, **kwargs) -> None: if isinstance(path, os.PathLike): path = str(path) if isinstance(path, str): path = path.encode('utf-8') data = bytes(sentinel_decrypt(path)) encoding = kwargs.get('encoding', None) if encoding is not None: data = data.decode(encoding) self._data = data self._size = len(self._data) self._pos = 0 def close(self) -> None: self._data = None def closed(self) -> bool: return self._data is None def tell(self) -> int: self._ensure_open() return self._pos def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: self._ensure_open() if whence == io.SEEK_SET: if offset < 0: raise ValueError(f'negative seek position {offset}') self._pos = offset else: raise io.UnsupportedOperation("can't do nonzero cur-relative seeks") return self._pos def read(self, n: int = -1) -> AnyStr: self._ensure_open() data = self._data[self._pos:self._pos+n] if n >= 0 else self._data[self._pos:] self._pos += len(data) return data def readline(self, limit: int = -1) -> AnyStr: self._ensure_open() data = self._data[self._pos:] stream = io.BytesIO(data) if isinstance(data, bytes) else io.StringIO(data) line = stream.readline(limit) self._pos += len(line) return line def readlines(self, hint: int = -1) -> List[AnyStr]: self._ensure_open() data = self._data[self._pos:] stream = io.BytesIO(data) if isinstance(data, bytes) else io.StringIO(data) lines = stream.readlines(hint) self._pos += sum([len(line) for line in lines]) return lines def __enter__(self) -> 'SentinelFileIo': return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: self.close() return exc_type is None def __iter__(self) -> Iterator[AnyStr]: self._ensure_open() while self._pos < self._size: yield self.readline() def _ensure_open(self) -> None: if self.closed(): raise ValueError('I/O operation on closed file.')
Все, что теперь остается сделать с кодом -- это поменять open() на sentinel_open() больше не трогая ни строчки. Теперь код выглядит так:
with sentinel_open(path, 'rb') as f: data = f.read() use_data(data)
На самом деле действий нужно чуть больше:
переименовать файл .py -> .pyx
вставить вышеприведенный кусок кода в файл
Теперь с кодом точно все. Осталось сгенерировать C++ код из – теперь уже – Cython кода вышеописанным способом, а также собрать, прилинковав libsentinel.a и библиотеку из Sentinel SDK.
Примеры вызовов:
g++ $(python3.8-config --cflags) -fPIC -g0 -s -shared lib.cpp -o lib.so -lsentinel -lhasp_cpp_linux_x86_64 g++ $(python3.8-config --cflags) -fPIC -g0 -s app.cpp -o app $(python3.8-config --libs --embed) -lsentinel -lhasp_cpp_linux_x86_64
Теперь у нас есть зашифрованный файл данных и бинарный ELF-файл, который сможет расшифровать их при загрузке. Результат достигнут? Казалось бы, да, но есть еще особенность.
В собранном файле, к которому был прилинкован libsentinel.a, можно найти строку с vendor code, имея который и Sentinel SDK, данные возможно расшифровать. На помощь нам приходит утилита из того же Sentinel SDK: Envelope, которая особым образом трансформирует файл, после чего вызов утилиты strings больше не выводит ни единой читаемой строки.
Пример вызова утилиты:
Sentinel-LDK/VendorTools/Envelope/linuxenv --vcf:company-product.hvc --fid:1 input-file.so output-file.so
А как же лицензионная защита упомянутая в заголовке статьи? После обработки утилитой Envelope, ELF-файл может быть использован только на машине с установленной лицензией. А это значит, что код под надежной защитой Вот такой способ мы нашли при установке клиенту On-Premises-версии Audiogram. Пишите в комментариях, была ли полезна вам моя статья, и делитесь лайфхаками, как вы решаете задачу с защитой кода и данных.
P.S.
Решение от Sentinel – это коммерческий продукт и нужна платная лицензия. И Sentinel ушел из России, но есть другие похожие решения. Например, Guardant.
