Как стать автором
Обновить
77.8

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

Время на прочтение6 мин
Количество просмотров11K

Всем привет, я Вячеслав Жуйко – 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.

Теги:
Хабы:
Всего голосов 16: ↑15 и ↓1+14
Комментарии17

Публикации

Информация

Сайт
mts.ai
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Анна Родина