Защита секретов с помощью технологии SRAM PUF

Команда Racoon Security постоянно находится в поиске новых технологий для применения в исследованиях и контрактном производстве. В очередной раз просматривая список докладов прошедших выставок Embedded World 2019 и Embedded World 2020, мы наткнулись на документ от NXP Semiconductors — Extend MCU Security Capabilities Beyond Trusted Execution with Hardware Crypto Acceleration and Asset Protection. Так мы узнали про технологию SRAM PUF, в основе которой лежит так называемая Физически неклонируемая функция (Physically unclonable function) на основе состояния памяти. Мы решили разобраться, как работает эта технология, есть ли у нее недостатки и как ее можно применить в наших разработках.

1dad3e835e762ffd066bffde74dcbcde.png

Безопасность секретов в embedded-устройствах

В основе любой защищенной системы, вероятно, должен находиться секрет. Рассуждая про embedded, мы можем предположить, что ключ или какая-то его часть будет находиться на устройстве — в защищенном участке памяти, фьюзах или антифьюзах. Таким образом, имея доступ к устройству, определенное количество времени, оборудования и денег, можно узнать этот секретный ключ, тем самым поставив безопасность устройства под сомнение. В качестве альтернативы ключ можно не хранить, но достоверно воспроизводить в процессе работы с помощью Физически неклонируемых функций, то есть PUF.

Что такое PUF

На Хабре уже есть хорошая статья (Физически неклонируемые функции: защита электроники от нелегального копирования), которая знакомит читателя с концепцией PUF. Но она имеет обзорный характер и не углубляется в практическую реализацию технологии с упоминанием конкретных производителей. Поэтому позволим себе вкратце рассказать, что такое PUF, после чего перейдем к реализации технологии на примере контроллеров NXP Semiconductors.

41141cfb3aed4525f21fac4e648c7508.jpg

Physically Unclonable Function — это функция, которая основана на уникальных физических характеристиках внутри каждого отдельно взятого объекта. В качестве такой характеристики может выступать, например, совокупность биометрических данных человека.

Рис. 1. Аналогия между биометрическими данными человека и Рис. 1. Аналогия между биометрическими данными человека и «биометрикой» микросхемы

На рисунке видно, что для идентификации человека мы можем использовать такие документы, как паспорт или свидетельство о рождении. Но эти документы в теории могут быть легко скопированы. Однако с помощью уникального набора биометрических данных мы можем достоверно определить, что перед нами находится конкретный представитель вида homo sapiens. Причем нам не обязательно хранить набор этих данных в явном виде. Достаточно составить алгоритм, который сможет извлекать данные, а затем воспроизводить этот «отпечаток» на основе вспомогательной информации.

У кремниевого устройства тоже есть свои уникальные характеристики, которые помогут нам отличить одну микросхему от другой. К таким характеристикам, к примеру, можно отнести уникальность транзисторов (их пороговых напряжений) внутри микросхемы, так как в процессе производства они будут изготовлены с определенной погрешностью. В свою очередь, это может быть использовано в PUF, основанной на состоянии памяти — SRAM PUF.

Углубляться в то, как работает SRAM, в рамках этой статьи не будем. Однако стоит напомнить, что SRAM — это энергозависимый тип памяти, то есть хранить информацию в ячейках памяти можно, только пока есть питание. Следовательно, при каждом включении устройства SRAM будет содержать неинициализированные значения. Эти первоначальные значения SRAM-ячеек будут случайными и уникальными для каждой микросхемы ввиду того, что транзисторы, из которых изготовлены эти ячейки, отличаются.

SRAM PUF

Итак, в основе SRAM PUF лежит состояние SRAM-ячеек при подаче питания. В этот момент ячейка может принимать значение логического '0' или '1'. Совокупность этих ячеек формирует уникальное состояние памяти — SRAM Startup Data. Однако не все ячейки будут содержать одно и то же значение от цикла к циклу, некоторые будут колебаться. Стабильность SRAM-ячейки при подаче питания обусловлена различием пороговых напряжений в ее «плечах». Чем меньше разность, тем большее влияние оказывает нестабильность температуры, питания, а также старение кремния. Более подробно об этом вы можете прочитать в документе White Paper The reliability of SRAM PUF. Так или иначе, использовать такой отпечаток напрямую нельзя — необходимо обеспечить его воспроизводимость с помощью алгоритма коррекции ошибок.

Рис. 2. Совокупность SRAM-ячеек и их стартовых значений служит для формирования уникального, но зашумленного SRAM-отпечаткаРис. 2. Совокупность SRAM-ячеек и их стартовых значений служит для формирования уникального, но зашумленного SRAM-отпечатка

Пример работы такого алгоритма мы рассмотрим чуть позже, когда познакомимся с методами SRAM PUF. А пока поверим на слово, что, пропустив такой зашумленный отпечаток через алгоритм, мы получим две сущности — воспроизводимый отпечаток Digital Fingerprint и вспомогательную информацию Helper Data.

Helper Data не содержит какого-либо секрета, но используется для того, чтобы восстанавливать точный отпечаток Digital Fingerprint из зашумленного отпечатка SRAM Startup Data. В процессе работы Digital Fingerpint не покидает пределов PUF-блока и не сохраняется после выключения питания. Для его воспроизведения нам необходимо сохранить вспомогательную информацию (Helper Data).

Рис. 3. Процесс формирования основных составляющих SRAM PUF – Digital Fingerprint и Helper DataРис. 3. Процесс формирования основных составляющих SRAM PUF — Digital Fingerprint и Helper Data

Таким образом, Digital Fingerprint может быть использован в качестве корневого ключа для защиты секретов — симметричных или асимметричных ключей и другой конфиденциальной информации.

SRAM PUF в контроллерах NXP

PUF-блок в контроллерах NXP Semiconductors реализован на основе IP-решения компании Intrinsic ID. На данный момент поддержка PUF-блока есть в контроллерах серий LPC5400 (Cortex-M4) и LPC5500 (Cortex-M33).

Рассматривать основные методы SRAM PUF будем на примере LPC55Sxx с помощью диаграмм, представленных в AN12324.

Существуют следующие методы:

  • Enroll — генерация кода активации (Activation Code);

  • Start — воспроизведение отпечатка (Digital Fingerprint);

  • SetKey — установка пользовательского ключа;

  • GenerateKey — установка случайного пользовательского ключа;

  • GetKey — получение пользовательского ключа.

PUF Enroll — генерация Activation Code

Чтобы начать работать с SRAM PUF, необходимо выполнить команду Enroll. Эту операцию следует проделать единожды для каждого устройства. В результате выполнения Enroll мы получаем уникальный Digital Fingerprint и соответствующий ему Activation Сode (Helper Data).

Как уже было сказано, значение Digital Fingerprint не сохраняется и не покидает PUF-блок, а вот Activation Code можно сохранить в любую энергонезависимую память (NVM), т. к. он не содержит секрета.

Digital Fingerprint представляет собой 256-битный ключ, а Activation Code — блок данных размером до 1192 байт (9536 бит).

Стоит отметить, что при каждом вызове команды Enroll мы получаем другие значения Digital Fingerprint и Activation Code, поэтому при потере Activation Code невозможно восстановить оригинальный Digital Fingerprint, а следовательно, и все секреты, которые были защищены с помощью этого отпечатка. Поэтому существует возможность отключить команду Enroll с помощью специального фьюза.

Копирование Activation Code на другое устройство бессмысленно, так как малейшие изменения в SRAM Startup Data приведут к получению совершенно другого Digital Fingerprint, а следовательно, сведут на нет возможность прочитать ключи и данные (секреты), защищенные оригинальным отпечатком.

Рис. 4. Метод Enroll, заключающийся в получении стабильного Digital Fingerprint и Activation Code (Helper Data) для его восстановленияРис. 4. Метод Enroll, заключающийся в получении стабильного Digital Fingerprint и Activation Code (Helper Data) для его восстановления

PUF Start — воспроизведение Digital Fingerprint

После выполнения Enroll и получения Activation Code можно использовать SRAM PUF для защиты ключей и данных. Для этого необходимо сбросить питание в PUF, а затем вызвать команду Start.

Activation Code загружается в PUF, и на основе новых значений SRAM Startup Data с помощью алгоритма коррекции ошибок воспроизводится Digital Fingerprint. После этого можно использовать команды SetKey, GenerateKey и GetKey.

Рис. 5. Метод Start, заключающийся в воспроизведении Digital Fingerprint по сохраненному ранее Activation CodeРис. 5. Метод Start, заключающийся в воспроизведении Digital Fingerprint по сохраненному ранее Activation Code

Пример работы алгоритма коррекции ошибок Fuzzy Extractor

Перед тем, как разобрать остальные методы, будет полезно наглядно показать, как именно происходит воспроизведение Digital Fingerprint.

В этом примере мы считаем, что Digital Fingerprint = SRAM PUF Response = SRAM Startup Data.

Для упрощения рассмотрим SRAM PUF Response из 1 байта. И опять же для упрощения будем считать, что у нас есть всего два так называемых Code Word — слова для коррекции ошибок:

  • C0 = 00000000;

  • C1 = 11111111.

  1. В процессе выполнения Enroll мы получаем SRAM PUF Response. Выбираем случайное кодовое слово (Code Word) и с помощью операции XOR получаем Helper Data (Activation Code).

  2. При следующем запуске в момент выполнения команды Start мы извлекаем изменившееся значение SRAM PUF Response (изменилось из-за нестабильности ячеек SRAM).

  3. Выполняем XOR SRAM PUF Response с Helper Data.

  4. Анализируем количество '1' и '0' в полученном значении:

    • Результат может быть ближе к C0 или к C1.

    • В случае с таким алгоритмом можно гарантировать исправление не более трех ошибочных битов в одном SRAM PUF Response.

  5. Выполняем XOR наиболее близкого Code Word с Helper Data и получаем обратно оригинальный отпечаток.

Рис. 6. Пример восстановления оригинального отпечатка с помощью Helper DataРис. 6. Пример восстановления оригинального отпечатка с помощью Helper Data

PUF SetKey — преобразование пользовательского ключа

Команда SetKey выполняет преобразование User Key (пользовательского ключа / данных) в Key Code — безопасную последовательность, которую можно хранить где угодно. User Key может принимать значение от 64 бит до 4096 бит. Из диаграммы нетрудно заметить, что переносить эту последовательность (Key Code) на другой микроконтроллер бессмысленно, так как Digital Fingerprint уникален.

Каждый Key Code имеет свой индекс. Key Code с индексом 0 после обратного преобразования будет недоступен из программной части. Он может быть напрямую определен для работы с AES-блоком, который можно использовать для защиты прошивки.

Рис. 7. Метод SetKey, используемый для защиты пользовательского ключа с помощью PUFРис. 7. Метод SetKey, используемый для защиты пользовательского ключа с помощью PUF

PUF GenerateKey — преобразование случайного пользовательского ключа

Также имеется возможность генерации случайного ключа внутри PUF-блока с последующим преобразованием в Key Code. Подробно останавливаться на этом не будем — рисунок говорит сам за себя.

Рис. 8. Метод GenerateKey, используемый для генерации случайного пользовательского ключа с последующей защитой PUF-блокомРис. 8. Метод GenerateKey, используемый для генерации случайного пользовательского ключа с последующей защитой PUF-блоком

PUF GetKey — восстановление пользовательского ключа

И, наконец, последний метод: GetKey выполняет обратное преобразование Key Code в пользовательские данные. Ранее мы говорили о том, что ключ с индексом 0 недоступен из программной части и работает напрямую с другими криптографическими функциями.

Так вот, помимо AES-блока, NXP Semiconductors предоставляет еще один симметричный алгоритм шифрования — PRINCE. Его особенность в том, что он очень быстрый и отлично подходит для того, чтобы разворачивать прошивку на лету. Это особенно актуально для серии LPC5400, в которой вообще нет энергонезависимой памяти внутри чипа. Эти контроллеры работают по принципу Execute-In-Place.

Поэтому у метода GetKey есть параметр KeySlot. Он позволяет определить, в какой из криптографических блоков (AES или PRINCE) будет отправлен ключ после обратного преобразования.

Рис. 9. Метод GetKey, используемый для обратного преобразования KeyCode в пользовательский ключРис. 9. Метод GetKey, используемый для обратного преобразования KeyCode в пользовательский ключ

Жизненный цикл устройства с SRAM PUF

После того, как мы разобрали каждый метод по отдельности, можно составить общую картину того, как эти функции связаны между собой.

Рис. 10. Методы SRAM PUF в одной картинкеРис. 10. Методы SRAM PUF в одной картинке

Сценарии применения

Итак, как же мы можем использовать SRAM PUF на практике? Для этого обратимся к документу AN12292, после прочтения которого можно обозначить три ключевые сферы применения SRAM PUF:

  • персонализация девайсов;

  • безопасная загрузка;

  • защита пользовательских ключей и данных.

Персонализация девайсов

При персонализации девайсов выполняется подготовка PUF-блока посредством команды Enroll. Для этого подготовим специальную прошивку — Enrollment Image. С ее помощью мы можем провести тестирование готового устройства, сгенерировать и защитить необходимые для работы ключи, а также составить нашу базу аутентичных устройств. Далее мы можем настроить устройство на получение и работу с прошивкой, которая защищена уникальным для каждого устройства ключом.

Персонализация может проходить как в доверенном сегменте (OEM или другое доверенное производство), так и на производстве, которому мы не доверяем.

В доверенном сегменте мы можем с помощью штатных средств безопасно персонализировать наши устройства и подготовить их к работе. Но если мы работаем в недоверенном сегменте, то нам необходимо выстроить цепочку доверия с использованием асимметричного шифрования и отдельных модулей безопасности (Hardware Security Module, HSM). В таком случае возникает проблема проверки публичного ключа на подлинность. Чтобы избежать подмены такого ключа злоумышленником, его необходимо подписать доверенным сертификатом производителя. Мы вернемся к этой проблеме позже, а пока давайте рассмотрим гипотетический процесс доставки прошивки на устройство с SRAM PUF.

Рис. 11. Процесс подготовки образа для передачи на производство (на основе информации из AN12291)Рис. 11. Процесс подготовки образа для передачи на производство (на основе информации из AN12291)

К примеру, на подготовительной стадии с Enrollment Image мы сгенерировали пару ключей — IP Binding Keys. Приватный ключ был защищен с помощью SRAM PUF и теперь хранится в виде Key Code. Публичный ключ (Public IPB Key) мы передаем на наше производство (Original Equipment Manufacturer, OEM).

У себя мы подготавливаем прошивку (Image Bundle) и закрываем ее с помощью симметричного ключа (Distribution Key). А симметричный ключ, в свою очередь, мы закрываем с помощью публичного ключа, полученного от конкретного устройства. Таким образом, мы можем подготовить образ, предназначенный для работы на конкретном микроконтроллере, после чего отправить его обратно на контрактное производство.

Однако, как уже было сказано ранее, возникает проблема проверки подлинности полученного нами публичного ключа (Public IPB Key). Появляется возможность для атаки, когда злоумышленник встает между устройством и нашим производством (Man-In-The-Middle) и вместо аутентичного публичного ключа микроконтроллера мы получаем публичный ключ атакующего.

Анализ документа AN12291 не дал четкого понимания, как можно решить эту проблему, поэтому мы написали NXP Semiconductors. Так как оригинальная технология принадлежит Intrinsic ID, нас направили за подробностями к ним. В процессе поиска решения мы нашли запись совместного вебинара Intrinsic ID и GlobalSign, в котором есть слайд, косвенно отвечающий на поставленный вопрос, — интеграция проверки подлинности должна быть заложена производителем микросхемы.

Рис. 12. Процесс персонализации (выдержка из вебинара Strong Device Identities Through SRAM PUF-based Certificates)Рис. 12. Процесс персонализации (выдержка из вебинара Strong Device Identities Through SRAM PUF-based Certificates)

Чуть позже нам ответили и сами Intrinsic ID, которые подтвердили эту информацию. Производство должно быть либо полностью доверенным, либо партия микроконтроллеров должна пройти подготовительную стадию Enroll у производителя, в процессе которой будут сгенерированы секреты с использованием так называемого CA (Certificate Authority) приватного ключа.

Предоставляет ли такую услугу NXP Semiconductors — мы не знаем. Скорее всего, да, но отдельный запрос мы не отправляли. В любом случае это скорее актуально только для крупных серий устройств. Таким образом, можно сделать вывод, что в случае использования SRAM PUF в контроллерах NXP Semiconductors, подготовительная стадия должна проходить в полностью доверенном сегменте.

Безопасная загрузка

Для безопасной загрузки в контроллерах NXP Semiconductors есть поддержка Secure Boot. В подробностях с процессом Secure Boot можно ознакомиться в документе AN12385 на странице 13. Ранее мы говорили о том, что в NXP Semiconductors есть криптографические блоки AES и PRINCE, а Key Code с определенным индексом может быть сразу использован для работы с этими блоками по специальной шине.

Таким образом, разработчику достаточно сгенерировать и сохранить у себя симметричный ключ, закрыть прошивку этим ключом, а после этого обернуть ключ в Key Code с нулевым индексом. Тем самым разработчик запрещает PUF-блоку передавать явное представление ключа куда-либо, кроме криптографических блоков.

Защита пользовательских ключей и данных

Ну и, конечно же, SRAM PUF может использоваться для защиты пользовательских ключей и данных во время работы вашего устройства. Конфиденциальную информацию, которую необходимо сохранить, можно обернуть в Key Code, тем самым сделав его содержимое читаемым только на конкретном девайсе. То же самое касается и асимметричных ключей — мы можем хранить только публичный ключ, а приватный обернуть в Key Code, разворачивая его только в том случае, когда он требуется для работы криптографии. Для создания пар асимметричных ключей в NXP Semiconductors есть отдельный криптографический модуль — CASPER.

Однако не стоит забывать, что максимальный размер User Key составляет всего 512 байт.

Рис. 13. Сценарий использования SRAM PUF для защиты персональной информацииРис. 13. Сценарий использования SRAM PUF для защиты персональной информации

Отладочная плата

Давайте теперь подробнее о том, на чем все это можно пощупать. Для оценки возможностей SRAM PUF на практике нами была закуплена отладочная плата LPC54S018-EVK. Стоимость такой платы в Чип и Дип на момент публикации этой статьи составляет 7960 рублей.

Рис. 14. Отладочная плата LPC54S018-EVKРис. 14. Отладочная плата LPC54S018-EVK

Краткий список того, что в ней есть:

  • программатор LINK2 Probe;

  • QSPI-флешка;

  • SDRAM;

  • Arduino UNO коннектор;

  • SD/MMC-слот;

  • RJ-45 Ethernet коннектор;

  • акселерометр;

  • 2 USB — Full & High Speed;

  • LEDs;

  • Stereo Audio;

  • Digital Microphone.

Вместе с отладочной платой поставляется внушительный список демонстрационных приложений, среди которых можно найти пример для работы с PUF. Также NXP Semiconductors поставляет свою Eclipse-подобную IDE, именуемую MCUXpresso.

После установки MCUXpresso и добавления SDK-пакета отладочной платы проект можно импортировать из раздела driver_examples → puf.

Приведем из этого примера фрагмент кода, иллюстрирующего создание и реконструкцию пользовательского ключа:

    #define PUF_ACTIVATION_CODE_SIZE 1192U
    #define PUF_INTRINSIC_KEY_SIZE   16
    ...
    status_t result;
    uint8_t activationCode[PUF_ACTIVATION_CODE_SIZE];
    uint8_t keyCode1[PUF_GET_KEY_CODE_SIZE_FOR_KEY_SIZE(PUF_INTRINSIC_KEY_SIZE)];
    uint8_t intrinsicKey[16] = {0};

    /* Perform enroll to get device specific PUF activation code */
    /* Note: Enroll operation is usually performed only once for each device. */
    /* Activation code is stored and used in Start operation */

    result = PUF_Enroll(PUF, activationCode, sizeof(activationCode));
    if (result == kStatus_EnrollNotAllowed)
    {
        PRINTF("Enroll is not allowed!\r\n");
        PRINTF("Error during Enroll!\r\n");
        return result;
    }

    PUF_Deinit(PUF, &conf);

	/* Reinitialize PUF after enroll */
    result = PUF_Init(PUF, &conf);
    if (result != kStatus_Success)
    {
        PRINTF("Error Initializing PUF!\r\n");
        return result;
    }

    /* Start PUF by loading generated activation code */
    result = PUF_Start(PUF, activationCode, sizeof(activationCode));
    if (result == kStatus_StartNotAllowed)
    {
        PRINTF("Start is not allowed!\r\n");
        PRINTF("Error during Start !\r\n");
        return result;
    }

    /* Generate new intrinsic key with index 1 */
    result = PUF_SetIntrinsicKey(PUF, kPUF_KeyIndex_01, PUF_INTRINSIC_KEY_SIZE, 
                                 keyCode1, sizeof(keyCode1));
    if (result != kStatus_Success)
    {
        PRINTF("Error setting intrinsic key!\r\n");
        return result;
    }

    /* Reconstruct intrinsic key from keyCode1 generated by PUF_SetIntrinsicKey() */
    result = PUF_GetKey(PUF, keyCode1, sizeof(keyCode1), 
                        intrinsicKey, sizeof(intrinsicKey));
    if (result != kStatus_Success)
    {
        PRINTF("Error reconstructing intrinsic key!\r\n");
        return result;
    }

    PRINTF("Reconstructed intrinsic key = ");
    for (int i = 0; i < 16; i++)
    {
        PRINTF("%x ", intrinsicKey[i]);
    }

    PUF_Deinit(PUF, &conf);
    PRINTF("\r\n\nExample end.\r\n");
    
    return kStatus_Success;

Перед началом работы выполняется генерация ActivationCode с помощью вызова API-функции PUF_Enroll. После чего происходит повторная инициализация и запуск PUF с помощью вызова PUF_Start. Далее в качестве примера генерируется случайный пользовательский ключ, который затем преобразуется в KeyCode. В конце концов происходит восстановление пользовательского ключа из KeyCode с помощью функции PUF_GetKey.

Рис. 15. Результат выполненияРис. 15. Результат выполнения

Какие проблемы есть у SRAM PUF?

Основная проблема SRAM PUF — это подверженность кремния старению. В процессе работы на транзисторы внутри SRAM-ячеек воздействуют такие факторы, как:

  • Negative-bias temperature instability (NBTI) — температурная нестабильность перехода;

  • Hot Carrier Injection (HCI) — инжекция горячих носителей;

  • Time-dependent gate oxide breakdown (TDDB) — пробой затвора.

Рис. 16. Факторы, влияющие на продолжительность жизненного цикла SRAM PUFРис. 16. Факторы, влияющие на продолжительность жизненного цикла SRAM PUF

Для борьбы с этими воздействиями применяют разные методики «антистарения», которые, согласно документу White Paper The reliability of SRAM PUF, значительно продлевают жизненный цикл устройства. Однако авторы документа не особо углубляются в то, какие конкретно методики они применяют в своих тестах. Предположительно, устройство с SRAM PUF, в котором применяются все технологии, направленные на борьбу с неблагоприятными факторами, должно прослужить не менее 25 лет.

Другая проблема — это возможность проведения в будущем криптографических атак с помощью методов машинного обучения. В сети мы нашли интересный доклад компании Криптонит (Физически неклонируемые функции в криптографии) с конференции РусКрипто 2020, из которого можно сделать следующий вывод: простые PUF могут ломаться за несколько часов или даже минут. Мы не смогли найти успешных кейсов по построению моделей для атаки на конкретную реализацию от Intrinsic ID, но это не значит, что такие атаки невозможны.

Выводы

Какие из всего этого можно сделать выводы?

Во-первых, SRAM PUF по своей концепции является достаточно перспективной технологией для защиты ключей и данных. Однако нам не удалось найти практическое и задокументированное описание работы алгоритма коррекции ошибок и алгоритма получения Activation Code. Следовательно, мы не можем быть уверены в том, что данная технология и ее конкретная реализация безопасны и действительно работают так, как это описано в документах NXP Semiconductors и Intrinsic ID. Иными словами, приходится полагаться на безопасность через неясность (security through obscurity).

Во-вторых, у SRAM PUF есть недостатки. Срок жизни любого полупроводникового девайса ограничен, но сама технология накладывает дополнительные издержки. Нельзя исключать возможность криптографических атак, которые смогут пошатнуть стойкость SRAM PUF в будущем.

В-третьих, для производства в недоверенном сегменте (как правило, это крупносерийное производство) контроллеры должны проходить подготовительную стадию у производителя микросхем, что накладывает дополнительные издержки. Но в случае мелкосерийного производства у разработчика есть возможность выполнить персонализацию у себя.

Дополнительная информация

Источники

6dtslv2uikkshhl7tmsi7fgjp0y.png

Raccoon Security — специальная команда экспертов НТЦ «Вулкан» в области практической информационной безопасности, криптографии, схемотехники, обратной разработки и создания низкоуровневого программного обеспечения.

Мы постоянно проводим индивидуальные стажировки и будем благодарны, если вы поделитесь ссылкой на эту статью с теми, кому это может быть интересно.

Оставить заявку на прохождение стажировки можно тут.

© Habrahabr.ru