Python: как писал PKI для СЭД

Потребовалось написать свой PKI посмотрев на то какой же сложный интерфейс у Microsoft CA.

Чтобы упростить интерфейс и свести все сложности к минимуму пришлось накидать свой PKI для СЭД ТЕЗИС, чтобы выписывать неквалифицированные электронные подписи.

Подписи эти работают только в пределах организации и юристами был подготовлен документ, чтобы НЭП имел юридическую силу. Предпологалось, что все внутренние документы (приказы и прочее) будут подписываться пользователями в СЭД.

По идее для этих задач есть библиотека CertSrv, который почему то не смог пройти аутентификацию на нашем сервере (тупо выдавал 401 как бы не обращался). Потому накидал свою библиотеку для работы по ssh. Пришлось проштудировать тонны документации. К сожалению код PKI не могу выложить поскольку код принадлежит организации, но библиотека это уже другой разговор. Вообще все коды стараюсь выложить с лицензией BSD, чтобы не было ограничений. Потому разберем именно работу библиотеки. Все то, что описано в readme репозитория обсуждать не будем, а посмотрим что же за кулисами. P.S. рассматривали вариант с openXPKI на случай если с библиотекой не срастется. И сидели бы вели 2 разные базы данных сертификатов администрируя все это вручную, а так все работает автоматически. Надо юзеру сертификат - сам генерирует. Заблокировали или удалили аккаунт юзеру в active directory - отозвался сертификат. Оч удобно.

Первая проблема

Есть куча разных PKCS контейнеров для сертификатов и видя какие экспортируются из Microsoft CA конечно же пришел к выводу, что нужный мне формат это PKCS#12. Оказалось что, если мне нужно выписать сертификат под одним пользователем, но чтобы в сертификате отображалось, что он выписан для другого пользователя, то мне нужно указывать аттрибут CMC, который является аттрубутом формата PKCS#10. Если почитать тут, то Microsoft преподносит это как отдельный контейнер, хотя если погуглить, то много где пишут, что CMC это всего лишь аттрибут PKCS#10. Так это или нет нам в принципе по барабану. Главное что поняли, что и для чего нам нужно.

Проблема с конфигурацией

Далее необходимо генерировать ini файл с конфигурацией сертификата. Сперва накидал такой код:

        config = configparser.ConfigParser()
        config['Version'] = {
            'Signature': '"$Windows NT$"'
        }
        config['NewRequest'] = {
            'Subject': f'"CN={user_fullname}"',
            'KeyLength': '2048',
            'KeySpec': '1',
            'KeyUsage': '0xa0',
            'ProviderName' : '"Microsoft RSA SChannel Cryptographic Provider"',
            'ProviderType': '1',
            'RequestType': 'CMC',
            'RequesterName': f'"{user_domain}\{requester}"',
        }
        config['RequestAttributes'] = {
            'CertificateTemplate': f'{self.cert_template}'
        }
        config['Extensions'] = {
            '2.5.29.17': '"{text}"',
            '_continue_': f'"email={user_mail}&"'
            '_continue_': f'"upn={user_pname}&"'
        }

Оказалось, что у configparser есть какой-то баг и потому нельзя записывать одинаковые ключи (последняя будет затирать предыдущие). Потому последний блок превратился в это:

        config['Extensions'] = {
            '2.5.29.17': '"{text}"',
            '_continue_': f'"email={user_mail}&"'
        }
        try:
            with open(f'/tmp/{requester}.ini', 'w') as configfile:
                config.write(configfile)
            self.call(f"sed -i '$ d' /tmp/{requester}.ini")
            self.call(f"echo '_continue_ = \"upn={user_pname}&\"' >> /tmp/{requester}.ini")
            if os.path.isfile(f"/tmp/{requester}.ini"):
                return True
            return False
        except Exception as e:
            return e

Согласен, выглядит грязно. Возможно когда нибудь попробую решить проблему с помощью структур (недавно освоил в golang и оказалось в python они тоже есть).

Разность SSH

На сервере стояла старая версия ssh, а на виртуалке для разработки более новая. Отсюда возникла проблема при работе scp, а я все голову ломал почему scp перестал работать (обновление провел во время разработки - не делайте так). Оказалось в новой версии добавилась какая-то фича и чтобы взаимодействовать с старой версией ssh теперь надо было добавлять параметр -T. Пришлось добавить аргумент backward_compat в метод инициализации класса:

    def scp_put(self, source, destination):
        try:
            if self.backward_compat:
                self.call(f"scp -o 'StrictHostKeyChecking no' -T {source} {self.user}@{self.server}:{repr(destination)}")
            self.call(f"scp -o 'StrictHostKeyChecking no' {source} {self.user}@{self.server}:{repr(destination)}")
            return True
        except Exception as e:
            return e

Только что обнаружил, что надо было добавить else :)

Подпись сертификата

При генерации сертификата оказывается если используется аттрибут CMC необходимо подписывать сертификат сертификатом CEP. Еще одна головная боль, которая пожалуй отняла больше всего времени на решение. Гугление ни к чему не привело потому решил разобраться, что это за зверек такой. CEP (Certificate Enrollment Policy) - политика регистрации сертификатов. Эта штука имеет свой шаблон конфигурации и свой сертификат, который надо генерить для пользователя из-под которого и будут генерироваться остальные сертификаты. В репозитории на github расписана вся настройка (настройка пользователя, настройка шаблона, настройка групповых политик). Вся инфа была собрана по крупицам из разных источников (ссылки естественно не сохранились).

Проблемы экспорта

Следующая проблема возникла при экспорте сертификатов. Сертификат выписывается, но не экспортируется в формате PKCS#12 который требуется СЭД'ом (только в виде голого X.509). Конечно можно экспортировать X.509, залить на linux сервер, сгенерировать новый private key и затолкать все это в PKCS#12 с помощью openssl. Но тогда появляется риск того, что serial и thumbprint будут отличаться от тех, что есть в базе данных Microsoft CA, что приведет к проблеме с отзывом сертификатов. Да и сама мысль о разности закрытых ключей не прельщало. К сожалению ссылка на статью не сохранилась, но где-то в инете нарыл статью, где чел решил эту проблему тупо установкой сертификата в личное хранилище текущего пользователя с последующим экспортом в PKCS#12, а в конце просто удаляет из хранилища. Как все просто - сам бы не додумался :)

Фигня с cmd.exe

Итак, есть конфигурация, есть сертификат cep, аплоадим конфиг на windows server и пытаемся запустить команды для генерации сертификата, но при этом все зависает как буд-то бы ожидая какого-то ввода от пользователя. Не смочь побороть эту проблему решил сделать метод для генерации batch скрипта, который аплоадится на windows server и запускается через ssh:

    def generate_payload(self, user_pname, cert_pass, cep_cert):
        pname = user_pname.split("@")
        requester = pname[0]
        ca_name = self.ca_name.split("\\")
        ca_name = list(filter(None, ca_name))
        remote_tmp = self.remote_tmp.split("\\")
        remote_tmp = list(filter(None, remote_tmp))
        if os.path.isfile(f"/tmp/{requester}.bat"):
            os.remove(f"/tmp/{requester}.bat")
        f = open(f"/tmp/{requester}.bat", "a")
        f.write(f"certreq -f -new -config {ca_name[0]}\{ca_name[1]} {remote_tmp[0]}\{remote_tmp[1]}\{requester}.ini {remote_tmp[0]}\{remote_tmp[1]}\{requester}.req\r\n")
        f.write(f"certreq -f -q -config {ca_name[0]}\{ca_name[1]} -sign -cert {cep_cert} {remote_tmp[0]}\{remote_tmp[1]}\{requester}.req {remote_tmp[0]}\{remote_tmp[1]}\{requester}_signed.req\r\n")
        f.write(f"certreq -f -submit -config {ca_name[0]}\{ca_name[1]} -attrib CertificateTemplate:{self.cert_template} {remote_tmp[0]}\{remote_tmp[1]}\{requester}_signed.req {remote_tmp[0]}\{remote_tmp[1]}\{requester}.cer\r\n")
        f.write(f"certutil -addstore -f MY {remote_tmp[0]}\{remote_tmp[1]}\{requester}.cer\r\n")
        f.write(f"certutil -repairstore MY {user_pname}\r\n")
        f.write(f"certutil -p {cert_pass} -exportPFX {user_pname} {remote_tmp[0]}\{remote_tmp[1]}\{requester}.pfx\r\n")
        f.write(f"certutil -privatekey -delstore MY {user_pname}")
        f.close()
        if os.path.isfile(f"/tmp/{requester}.bat"):
            return True
        return False

В таком виде вроде бы все работает.

Конец

Ну и в целом все. На все про все т.е. чисто на написание этой мелкой иблиотеки ушло порядка 3 месяцев. Из них 1 месяц вылетел в трубу поскольку сдох жесткий диск, а код не удалось спасти (почему-то решил не коммитить в git пока не получу более-менее рабочий код). Пришлось начинать все с нуля, а сам PKI накидал за неделю на flask используя этот caapi и еще adapi из статьи про Active Directory. В итоге вместо монструозного веб-интерфейса Microsoft CA, в котором сам админ ногу сломит, получилось сделать интерфейс с одной кнопкой generate digital signature, если нет сертификата и с двумя кнопками download и revoke, если сертификат уже был сгенерирован. Если пользователь нечайно удалил сертификат, то может зайти и заново скачать его. Если сертификат был скомпромитирован, то просто отзывает и генерирует новый.