Как установить лицензионную защиту кода на Python и обезопасить данные с помощью HASP?

от автора

Всем привет, я Вячеслав Жуйко – 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) -&gt; None:     self._data = None  def closed(self) -&gt; bool:     return self._data is None  def tell(self) -&gt; int:     self._ensure_open()     return self._pos  def seek(self, offset: int, whence: int = io.SEEK_SET) -&gt; int:     self._ensure_open()     if whence == io.SEEK_SET:         if offset &lt; 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) -&gt; AnyStr:     self._ensure_open()     data = self._data[self._pos:self._pos+n] if n &gt;= 0 else self._data[self._pos:]     self._pos += len(data)     return data  def readline(self, limit: int = -1) -&gt; 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) -&gt; 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) -&gt; 'SentinelFileIo':     return self  def __exit__(self, exc_type, exc_val, exc_tb) -&gt; bool:     self.close()     return exc_type is None  def __iter__(self) -&gt; Iterator[AnyStr]:     self._ensure_open()     while self._pos &lt; self._size:         yield self.readline()  def _ensure_open(self) -&gt; None:     if self.closed():         raise ValueError('I/O operation on closed file.')</code></pre><p>Все, что теперь остается сделать с кодом – это поменять <code>open()</code> на <code>sentinel_open()</code> и больше не трогать ни строчки. Теперь код выглядит так:</p><pre><code class="python">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. Пишите в комментариях, была ли полезна вам моя статья, и делитесь лайфхаками, как вы решаете задачу с защитой кода и данных.


ссылка на оригинал статьи https://habr.com/ru/company/mts_ai/blog/678928/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *