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

CodeSigning для разработчиков под Windows по новым правилам

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров2.6K

С 1.06.2023 году вступили в действие новые требования к сертификатам для подписи кода (aka CodeSigning), которые значительно осложнили жизнь разработчиков ПО. Суть изменений - прощай старый добрый PFX, закрытые ключи теперь должны быть неизвлекаемыми и некопируемыми. Примеры изменений у поставщиков: раз, два, три - в общем-то у всех одно и тоже.

Мы, как российские разработчики, оказались точно также затронуты этими нововведениями. До этого у нас уже был действующий сертификат, который был задеплоен на нужные виртуальные машины, где собираемый софт без проблем подписывался CI/CD-скриптами. Но часы тикали, и до окончательного отыквивания сертификата с ключом в PFX необходимо было решить, как жить дальше.

Что предлагает нам рынок

Для хранения закрытых ключей поставщики предлагают +/- одни и те же три варианта:

  • USB-токены.

  • HSM (hardware security module).

  • Облачные криптопровайдеры.

Каждый из этих вариантов имеет свои плюсы и минусы, которые можно свести к этой таблице:

Особенность

USB

HSM

Cloud

Цена

Низкая

Высокая

Средняя

Физическая надёжность носителя

Средняя

Высокая

Высокая

Сложность настройки

Низкая

Высокая

Низкая

Безопасность

Средняя

Очень высокая

В теории - высокая, на практике возможны нюансы

Далее моё личное краткое резюме по всем классам.

HSM

Отдельный специализированный компьютер для хранения ключей и проведения криптографических операций. Очень специфическая штука, редкая, обычно сертифицированная по всем необходимым стандартам и нормам, и крайне дорогая, используется в основном там, где это действительно необходимо: центры сертификации, государственные организации, банки, и прочие, где безопасности уделяется серьёзное внимание. Например, облачные криптопровайдеры по сути продают доступ к массиву своих HSM как сервис.

Облачные криптопровайдеры

Как упомянул выше, облачный криптопровайдер - это сервис, предлагающий дистанционное использование массива провайдерских HSM. Облако обеспечивает: генерацию и хранение ключей, выполнение операций, требующих закрытого ключа (шифрование и генерация ЭП). Естественно, всё это доступно через интернет, гарантируется N девяток к доступности и пр. Хороший вариант, если вы: доверяете облачному провайдеру (во всех смыслах - см. типичную ситуацию с облаками после февраля 2022), а также готовы (и имеете право!) хранить ключи шифрования на стороне. Цены у всех разные, но довольно кусачие, ибо HSM и остальные инфраструктура, обязательные сертификации и аудиты, квалифицированный персонал для обслуживания всего этого стоят дорого.

USB-токен

Самый доступный и дешёвый вариант. Хорошо известные устройства в России: Рутокен, JaCarta и им подобные. Надёжность носителя не ахти, от частого включения/отключения могут ломаться разъёмы, сами устройство иногда дохнут - за такую-то цену и не удивительно. Зато легко настроить и использовать.

Как показывает практика, большинство небольших компаний, выбирают этот вариант как наиболее доступный. Поэтому дальше речь пойдёт именно про него и только в контексте вопросов организации подписи кода.

Проблемы

И так, вы наконец-то купили USB-токен и выпустили сертификат. С какими проблемами возможно придётся столкнуться? В общем-то, все они проистекают из требований о неизвлекаемости и некопируемости закрытого ключа.

Ключ один, а использовать нужно на нескольких ПК

В отличие от PFX, который можно раскидать по нужным компьютерам, USB-токен можно физически подключить только на одной машине одновременно. Вариантов решения не так уж и много - устройства USB-over-IP. Как ставший уже классикой AnywhereUSB, так и его отечественный аналоги, например такой. Слышал, что есть и чисто софтверные решения под для организации подобного доступа к USB по IP. Сам, правда, не сталкивался, так что если кому-то интересно, то гугл в помощь. Но, в любом исполнении, подобное решение избавляет только от необходимости физически переставлять токен из компьютера в компьютер. Одновременная работа токена на нескольких клиентских устройствах всё также не поддерживается.

Ключ в физическом USB-разъёме, а использовать его надо в виртуальной машине

Типичная проблема USB-устройств, далеко не каждый гипервизор умеет их пробрасывать в гостевые ВМ. Например, VMWare позволяет, а Hyper-V - нет. Для тех, кто не позволяет, остаётся только ранее рассмотренный вариант с USB-over-IP. Но и здесь получается, что токен работает только одной виртуальной машине одновременно.

Необходимость ввода PIN-кода

Для использования закрытого ключа необходимо вводить PIN-код. В режиме ручной подписи - это допустимо, но любые средства автоматизированной подписи, в т. ч. CI/CD-скрипты, не предполагают отображение каких-либо диалоговых окон. Даже если такое окно и отобразится, то никто его не увидит на сервере, который находится неизвестно где, и на кнопку ОК не нажмёт. В итоге, операция подписи или отвалится по таймауту, или просто зависнет - зависит от реализации драйверов.

Ситуация усугубляется тем, что утилита signtool, используемая для подписи исполняемых файлов и установочных пакетов под Windows, не позволяет передать PIN-код как параметр. Да и разработчики драйверов носителей ключей не стремятся добавлять (или по крайней мере, широко афишировать) опции сохранения PIN-кодов и/или отключения UI для их ввода. Ведь предполагается, что USB-токены, являющиеся личными устройством, как раз должны обеспечивать безопасность пользователя. Что совершенно понятно в случаях персональных ключей электронных подписей. Ведь ни пользователю, ни производителю устройств не нужно, чтобы какой-нибудь примитивный зловред подписал за вас пару платёжных поручений, переводящих все нажитые накопления какому-нибудь Хулио из Колумбии.

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

Про сохранение PIN-кодов

Да, хранить PIN-коды для автоматизированной обработки - совсем не по фэншую, и все возможные руководства не советуют так делать. Но мы тут насущные проблемы решаем, а не боремся за абстрактную безопасность.

И что со всем этим делать?

Рассказываю, как я решил для себя эту задачу. После некоторых проб и экспериментов оформилось решение в виде системы их двух компонентов:

  • Веб-сервер на ASP.NET Core с генерацией электронной подписи и ввода PIN-кода посредством вызова функций WinAPI CNG (Cryptography API: Next Generation).

  • Клиентская часть в виде Powershell-скрипта, запускающего signtool со специальными ключами и обращающегося к нашему серверу за операцией генерации подписи.

Hidden text

Почему именно CNG, а не старый добрый CryptoAPI, будет понятно далее.

Теперь всё тоже самое, но по порядку и с подробностями.

Суть решения

Недолгий гуглинг показал, что signtool умеет разделять подпись файла на четыре этапа.

  1. С помощью вызова с параметром -dg формируем только заготовку электронной подписи в формате CMS, при этом сам хэш, который необходимо подписать, сохраняется в отдельном файле в кодировке Base64. Например, следующий вызов для подписи нашего пробного приложения c:\temp\myapp.exe сертификатом c:\temp\cert.cer:

signtool sign -f c:\temp\cert.cer -fd sha256 -dg c:\temp -v c:\temp\myapp.exe

сгенерирует два новых файла в рабочем каталоге (в нашем примере c:\temp). Первый - myapp.exe.p7u - содержит заготовку CMS-подписи, нам он пока не интересен. Второй - myapp.exe.sig - как раз содержит нужное нам значение рассчитанного хэша, закодированное в Base64.
2. Теперь необходимо как-то подписать хэш из файла myapp.exe.dig. Этим будет заниматься наш веб-сервис, который предполагается установить на компьютер, к которому подключён USB-токен (физически или через USB-over-IP, не принципиально). На вход сервису будет передаваться значение хэша, алгоритм хэширования (SHA256 в примере выше) и отпечаток сертификата, результатом будет рассчитанная электронная подпись. Получив подпись, её необходимо сохранить в том же рабочем каталоге в файле myapp.exe.dig.signed, тоже в кодировке Base64.
3. Всё готово для сборки подписи и вставке её в исполняемый файл. Вызываем:

signtool sign -di c:\temp -v c:\temp\myapp.exe

Параметр -di предписывает утилите signtool взять сырое значение электронной подписи из файла myapp.exe.dig.signed, завершить формирование CMS-подписи, и вставить её в исполняемый файл.
4. При такой схеме нельзя выполнить подпись и простановку штампа времени за один вызов signtool, поэтому заставляем её проставить штамп времени на подпись отдельным вызовом:

signtool timestamp -tr http://timestamp.globalsign.com/tsa/r6advanced1 -td sha256 -v c:\temp\myapp.exe

Voilà, файл подписан, что и требовалось.

Реализация серверной части

Для обслуживания запросов от разных наших клиентов (CI/CD-скрипты) я написал простенький веб-сервис на ASP.NET Core, который развёрнут в виде Windows-службы на сервере, в который вставлен USB-токен.

Веб-часть до ужаса примитивна. Есть один minimal API endpoint, который и обрабатывает наши POST-запросы на генерацию подписи, и есть блок считывания настроек используемых сертификатов.

Начнём с настроек

Список доступных сертификатов и PIN-кодов к ним будем хранить в стандартном файле настроек приложения appsettings.json в виде JSON. У меня он выглядит так:

{
	"Logging": {
		"LogLevel": {
			"Default": "Information",
			"Microsoft.AspNetCore": "Warning"
		}
	},
	"AllowedHosts": "*",
	"Signer": {
		"Certificates": [
			{
				"Thumbprint": "fc276a503b9b2f24d644424fff823bdf6bf6f603",
				"KeyName": "2a63f603f43743848ea243130bf1702e",
				"IsMachineKey": false,
				"Pin": "12345678"
			}
		]
	}
}

Первые два раздела стандартные для ASP.NET, нас интересует последний, Signer. В нём есть только один параметр с массивом сертификатов, ключами которых мы планируем подписывать что-либо. Каждый сертификат задаётся четырьмя параметрами:

  • Thumbprint - текстовое представление SHA1-отпечатка сертификата, используется для идентификации. Именно это значение будет передаваться клиентом как один из параметров нашего сервиса. Можно подсмотреть в свойствах сертификата любым способом.

  • KeyName - имя ключевого контейнера в криптопровайдере Microsoft Smart Card Key Storage Provider.

    Для ключа на Рутокен это имя можно подсмотреть в панели управления Рутокен

    Для других устройств смотрите соответствующую документацию разработчика, или извлекайте кодом с помощью перечисления ключевых контейнеров с функцией NCryptEnumKeys (поле pszName структуры NCryptKeyName).

  • IsMachineKey - признак что ключ принадлежит компьютеру, а не текущему пользователю.

  • Pin - наш сохранённый PIN-код для USB-токена.

В самом приложении эти настройки инкапсулированы в пару классов:

public sealed class Certificate
{
	public required string Thumbprint { get; set; }
	public required string KeyName { get; set; }
	public bool IsMachineKey { get; set; }
	public required string Pin { get; set; }
}

public sealed class SignerOptions
{
	public List<Certificate> Certificates { get; set; } = [];
}

При инициализации приложения классы с настройками регистрируются в DI-контейнере стандартным вызовом:

builder.Services.Configure<SignerOptions>(builder.Configuration.GetSection("Signer"));

Обработчик запросов

Для обработки входящих запросов на подпись используем Minimal API:

app.MapPost("/sign/base64hash", 
	async ([FromQuery] string base64hash, [FromQuery] string certificateThumbrint, [FromQuery] string hashAlgId, IOptionsMonitor<SignerOptions> options, CancellationToken ct) => 
		await SignBase64HashAsync(base64hash, certificateThumbrint, hashAlgId, options.CurrentValue, ct));

Сервис реализован в виде метода POST по адресу /sign/base64hash с тремя параметрами, которые передаются через параметры запроса:

  1. base64hash - кодированное в Base64 значение хэша, которого нужно подписать.

  2. certificateThumbrint - текстовое представление отпечатка сертификата, которым необходимо подписать хэш.

  3. hashAlgId - название алгоритм хэширования, который должен совпадать с константами из CNG.

SignBase64HashAsync - наш метод, который будет выполнять всю работу, рассмотрим его подробнее:

private static async Task<IResult> SignBase64HashAsync(string base64hash, string certificateThumbrint, string hashAlgId, SignerOptions options, CancellationToken ct)
{
	var certificate = options.Certificates.FirstOrDefault(c => c.Thumbprint.Equals(certificateThumbrint, StringComparison.OrdinalIgnoreCase));
	if (certificate is null)
		return Results.Problem($"The certificate with the thumbprint '{certificateThumbrint}' is not allowed.", null, StatusCodes.Status403Forbidden);
	var hash = Convert.FromBase64String(base64hash);
	var semaphore = _keySemaphores.GetOrAdd(certificate, c => new(() => new(1))).Value;
	await semaphore.WaitAsync(ct);
	try
	{
		var signature = SignCore(hash, hashAlgId, certificate);
		return Results.Bytes(Encoding.ASCII.GetBytes(Convert.ToBase64String(signature, Base64FormattingOptions.None)), "application/octet-stream");
	}
	finally
	{
		semaphore.Release();
	}
}

На вход он получает параметры запроса и указатель на объект SignerOptions с настройками приложения. Сначала функция выполняет поиск сертификата с заданным отпечатком, если он не найден, то на клиента возвращается ошибка в формате Problem Details.
Если сертификат найден в настройках, то выполняется поиск и при необходимости создание семафора, обеспечивающего доступ к ключу только одному потоку приложения одновременно. Сам словарь семафоров объявлен так:

private static ConcurrentDictionary<Certificate, Lazy<SemaphoreSlim>> _keySemaphores = new();

Далее происходит вход в семафор и выполняется метод SignCore, который и выполняет непосредственно подписание. Полученный байтовый массив - это и есть значение подписи, которое кодируется в Base64 и отправляется клиенту.

Подпись

Сначала я хотел использовать для подписи CryptoAPI (ранее немного освещал его использование именно для .NET: раз и два). Но с ним я зашёл в тупик. Оказалось, что функция CryptSignHash принимает на вход ссылку HCRYPTHASH, то есть ссылку на объект, выполняющий хэширование. У нас же хэш уже вычислен, и нужно только подписать его. Но способа передать только значение хэша без выполнения самого хэширования я не нашёл. Да ещё, и весь CryptoAPI объявлен deprecated, и сама MS настоятельно рекомендует перейти на CNG, который как ни странно, появился ещё в составе Windows Vista, но не снискал особой популярности, оставаясь в тени своего предшественника.

После небольшого исследования стало понятно, что NCryptSignHash - функция подписи в CNG - отделена от хэширования и принимает на вход произвольный байтовый массив, что нам и требуется.

Итак, сам код. Для P/Invoke я использовал свою библиотеку функций (nuget, github), вы можете использовать любую другую или написать свои переходники к функциям WinAPI.

private static unsafe byte[] SignCore(ReadOnlySpan<byte> inData, ReadOnlySpan<char> hashAlg, Certificate certificate)
{
	nint hProvider;
	fixed (char* providerName = MS_SMART_CARD_KEY_STORAGE_PROVIDER)
		NCryptOpenStorageProvider(&hProvider, providerName, 0).VerifyWinapiErrorCode();
	try
	{
		nint hKey;
		fixed (char* pKeyName = certificate.KeyName)
			NCryptOpenKey(hProvider, &hKey, pKeyName, 0U, NCRYPT_SILENT_FLAG | (certificate.IsMachineKey ? NCRYPT_MACHINE_KEY_FLAG : 0U)).VerifyWinapiErrorCode();
		try
		{
			fixed (char* pPin = certificate.Pin, pParam = NCRYPT_PIN_PROPERTY)
				NCryptSetProperty(hKey, pParam, pPin, (uint)(certificate.Pin.Length + 1) * 2, 0U).VerifyWinapiErrorCodeInList(ERROR_SUCCESS);

			var inDataLen = (uint)inData.Length;
			uint outDataLen;
			fixed (char* pHashAlg = BCRYPT_SHA256_ALGORITHM)
			fixed (byte* pInData = inData)
			{
				var padding = new BCRYPT_PKCS1_PADDING_INFO() { pszAlgId = pHashAlg };
				NCryptSignHash(hKey, &padding, pInData, inDataLen, null, 0, &outDataLen, NCRYPT_PAD_PKCS1_FLAG).VerifyWinapiErrorCode();
				var outData = new byte[(int)outDataLen];
				fixed (byte* pOutData = outData)
					NCryptSignHash(hKey, &padding, pInData, inDataLen, pOutData, outDataLen, &outDataLen, NCRYPT_PAD_PKCS1_FLAG).VerifyWinapiErrorCode();
				return outData;
			}
		}
		finally
		{
			NCryptFreeObject(hKey);
		}
	}
	finally
	{
		NCryptFreeObject(hProvider);
	}
}

Код прост и прямолинеен:

  1. Вызовом NCryptOpenStorageProvider получаем ссылку на встроенный провайдер смарт-карт (константа MS_SMART_CARD_KEY_STORAGE_PROVIDER).

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

  3. Передаём PIN-код ключа с помощью NCryptSetProperty и константы NCRYPT_PIN_PROPERTY.

  4. Наконец, генерируем саму подпись парой вызовов NCryptSignHash. Первый вызов вернёт размер буфера под подпись, второй - заставит USB-токен рассчитать её значение.

  5. Очищаем все выделенные неуправляемые ресурсы: ссылки на ключ и на провайдер - вызовами NCryptFreeObject.

На этом с сервером почти всё. Осталось две задачи:

  1. Обеспечить некоторое разграничение прав. Делаете любым подходящим вам способом, я делал с помощью API Key по этой статье на Хабре.

  2. Настроить хостинг в Windows-службе согласно стандартной доке. Кстати, службе не обязательно давать права LocalSystem, у меня хватило и LocalService.

Реализация клиентской части

Для использования только что созданного веб-сервиса и избавления от повторяющихся вызовов signtool я написал простенький Powershell-скрипт Sign-ExecutableFile.ps1, выполняющий всю грязную работу:

[CmdletBinding()]
param(
    [Parameter(Mandatory=$true, ValueFromPipeline = $true, HelpMessage="A target file name")]
    [ValidateNotNullOrEmpty()]
    [string] $TargetFileName,

    [Parameter(Mandatory=$true, HelpMessage="A full path to the SignTool utility")]
    [ValidateNotNullOrEmpty()]
    [string] $SignToolPath,

    [Parameter(Mandatory=$true, HelpMessage="A certificate file name")]
    [ValidateNotNullOrEmpty()]
    [string] $CertFileName,

    [Parameter(Mandatory=$true, HelpMessage="An URI of signing service")]
    [ValidateNotNullOrEmpty()]
    [string] $SigningServiceUri,

    [Parameter(Mandatory=$true, HelpMessage="An API key for signing service")]
    [ValidateNotNullOrEmpty()]
    [string] $SigningServiceApiKey,

    [Parameter(Mandatory=$false, HelpMessage="A digest algorithm name for signing")]
    [ValidateSet("SHA256", "SHA384", "SHA512")]
    [string] $DigestAlgorithm = "SHA256",

    [Parameter(Mandatory=$false, HelpMessage="A timestamp authority URI")]
    [ValidateNotNullOrEmpty()]
    [string] $TsaUri = $null,

    [Parameter(Mandatory=$false, HelpMessage="A digest algorithm name for timestamping")]
    [ValidateSet("SHA256", "SHA384", "SHA512")]
    [string] $TsaDigestAlgorithm = "SHA256",

    [Parameter(Mandatory=$false, HelpMessage="A working directory for placing intermediate results")]
    [string] $WorkingDirectory
)
begin {
    $ErrorActionPreference = "Stop"
    if (![System.String]::IsNullOrEmpty($WorkingDirectory))
    {
        $TargetWorkingDirectory = $WorkingDirectory
    }
    else
    {
        Write-Warning "The working folder is not specified, temporary files will be placed next to the executable files."
    }
    
    $certBytes = [System.IO.File]::ReadAllText($CertFileName)
    $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertFileName)
}
process {
    Write-Host
    Write-Host ("Signing the file '" + $TargetFileName + "'") -ForegroundColor White

    if ([System.String]::IsNullOrEmpty($WorkingDirectory))
    {
        $TargetWorkingDirectory = [System.IO.Path]::GetDirectoryName($TargetFileName)
    }
    else
    {
        $TargetWorkingDirectory = $WorkingDirectory
    }
    $tempFilePrefix = [System.IO.Path]::Combine($TargetWorkingDirectory, [System.IO.Path]::GetFileName($TargetFileName))
    $digestFileName = $tempFilePrefix + ".dig"
    $signatureFileName = $digestFileName + ".signed"
    $cmsFileName = $tempFilePrefix + ".p7u"
    try
    {
        Write-Host ("Generating a digest for the file '" + $TargetFileName + "'") -ForegroundColor Cyan
        & $SignToolPath sign -f "$CertFileName" -fd $DigestAlgorithm -ph -dg "$TargetWorkingDirectory" -q "$TargetFileName"

        Write-Host ("Calculating a signature for the digest from the file '" + $digestFileName + "'" ) -ForegroundColor Cyan
        $serviceUri = [System.Uri]::new($SigningServiceUri)
        $serviceUri = [System.Uri]::new($serviceUri, ("sign/base64hash?base64hash=" + [System.Uri]::EscapeDataString([System.IO.File]::ReadAllText($digestFileName)) + "&hashAlgId=" + $DigestAlgorithm + "&certificateThumbrint=" + [System.Uri]::EscapeDataString($cert.Thumbprint)))
        Invoke-RestMethod -Uri $serviceUri -Method Post -Headers @{ 'X-API-Key' = $SigningServiceApiKey } -OutFile $signatureFileName

        Write-Host ("Injecting a signature into the file '" + $TargetFileName+ "'" ) -ForegroundColor Cyan
        & $SignToolPath sign -di "$TargetWorkingDirectory" -q "$TargetFileName"

        if (-not [System.String]::IsNullOrEmpty($TsaUri))
        {
            Write-Host ("Injecting a timestamp into the file '" + $TargetFileName+ "'" ) -ForegroundColor Cyan
            & $SignToolPath timestamp -tr "$TsaUri" -td $TsaDigestAlgorithm -q "$TargetFileName"
        }
    }
    finally
    {
        Remove-Item -Path $digestFileName -Force -ErrorAction SilentlyContinue
        Remove-Item -Path $signatureFileName -Force -ErrorAction SilentlyContinue
        Remove-Item -Path $cmsFileName -Force -ErrorAction SilentlyContinue
    }
}
end {
}

Скрипт принимает все необходимые значения через параметры, назначение которых вполне очевидно из исходника. Для каждого элемента из входной коллекции файлов скрипт выполняет нужную череду вызовов signtool и обращений к нашему сервису подписи. В конце производится зачистка всех временных файлов, созданных утилитой signtool.

Пример вызова для подписи всех EXE-файлов в каталоге c:\temp, включая подкаталоги:

gci -Path "c:\temp\*.exe" -Recurse | .\Sign-ExecutableFile.ps1 -CertFileName c:\temp -SignToolPath "c:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" -SigningServiceUri "http://localhost:8145" -SigningServiceApiKey "myApiKey"  -TsaUri "http://timestamp.globalsign.com/tsa/r6advanced1"

Заключение

В сухом остатке, задача по подписи кода из CI/CD с ключами на USB-токенах была успешно решена, "веб-сервис крутится, сигнатурки мутятся". Буду рад, если предложенное решение облегчит кому-то рутинные операции.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы решили для себя проблему подписи кода с новыми правилами хранения закрытых ключей?
18.18% Токен USB, подписываю локально.4
9.09% Токен USB, пробрасываю куда надо с помощью решений USB-over-IP.2
27.27% Токен USB + свой сервер для всех своих скриптов.6
0% Есть HSM, проблемы обычных людей меня не касаются.0
9.09% Ушёл в крипто-облако со всеми сопутствующими рисками.2
27.27% Отказался от подписи кода.6
9.09% Ещё не решил окончательно, рассматриваю варианты.2
Проголосовали 22 пользователя. Воздержались 10 пользователей.
Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 5: ↑5 и ↓0+6
Комментарии23

Публикации

Работа

Ближайшие события