О сертификатах

2019-03-08

Понадобилось нам по работе поиграть в Роскомнадзор. Сделать так, чтобы страница блокировки работала через HTTPS.

Тут нужна магия с сертификатами.

TLS/SSL сертификаты — это пара ключей. Асимметричной криптографии. И связанная с публичным ключом метаинформация: кто таков, для чего нужен, и тому подобное.

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

Ну а публичный ключ, в виде сертификата, наоборот, выставляется публично. В результате клиент знает сервер в лицо. Знает, что сервер, предъявивший этот сертификат, действительно является владельцем этого сертификата.

SSL handshake

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

Сервер действительно может сам себе выдумать сертификат. Такие сертификаты называют самоподписанными. Но браузеры таким не доверяют.

Браузеры доверяют центрам сертификации. Certificate Authority, CA.

Эти центры выдают, то есть подписывают сертификаты серверов. У CA есть тоже свой сертификат. Со своим приватным ключом. Вот он и подписывает своим приватным ключом публичный ключ сервера. А заодно и метаинформацию. В результате на сертификате появляется цифровая подпись, которая говорит: «Я, CA такой-то, проверил, что это действительно такой сервер, который указан в этом сертификате, и это действительно его публичный ключ».

На самом деле в любом приличном CA есть несколько приватных ключей и сертификатов для подписывания сертификатов обычных серверов. И они, в свою очередь, подписаны другими ключами и сертификатами, а они другими... Формируется так называемая цепочка доверия. А в начале цепочки стоит самоподписанный (сам по себе) корневой сертификат данного CA.

Chain of trust

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

Нынче в вебе самый популярный центр сертификации — Let's Encrypt. Потому что он бесплатный. И потому что он выдаёт сертификаты автоматически. Нужно лишь доказать, что данный сервер действительно владеет этим доменом (хостит его). Это доказательство делается тоже автоматически. Бот Let's Encrypt размещает на сервере случайные файлы, а центр сертификации проверяет, что данные файлы действительно появились на сервере в соответствующем домене.

Но в случае страницы блокировки мы, блокировщики, не владеем доменом, который заблокировали. Мы обманным путём показываем пользователю страницу блокировки. И, в случае HTTPS, эта страница блокировки должна показывать пользователю такой сертификат, которому он бы доверял.

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

Это — базовая идея. Дальше начинаются нюансы.

В качестве простенького CA можно взять EasyRsa. Это такой толстенький shell скрипт и несколько файлов конфигурации, которые используют самый обычный OpenSSL, чтобы сгенерировать корневой сертификат, и генерировать и подписывать им другие сертификаты и ключи.

Как сделать сертификат, подходящий для любого домена?

Раз есть wildcard сертификаты типа "*.example.com", почему бы не попробовать супер-wildcard "*"? Сделал. Не работает. Браузер — не дурак. Wildcardа "*" не предусмотрено.

Но как? Пришлось подсмотреть, как работает страница блокировки OpenDNS (он же теперь Cisco Umbrella). А работает она очень интересно.

OpenDNS выдаёт сертификат под конкретный домен. Никаких wildcard. Причём сертификат подписан позавчерашним днём, и действителен в течение пяти дней. А в качестве сервера выступает некий openresty.

$ curl -4 https://gelin.ru/about/ -v -k
*   Trying 146.112.61.104...
* TCP_NODELAY set
* Connected to gelin.ru (146.112.61.104) port 443 (#0)
...
* SSL connection using TLSv1.2 / AES256-GCM-SHA384
* ALPN, server did not agree to a protocol
* Server certificate:
*  subject: C=US; ST=California; L=San Francisco; O=OpenDNS, Inc.; CN=gelin.ru
*  start date: Feb 23 12:53:42 2019 GMT
*  expire date: Feb 28 12:53:42 2019 GMT
*  issuer: CN=Cisco Umbrella Secondary SubCA ams-SG; O=Cisco
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
> GET /about/ HTTP/1.1
> Host: gelin.ru
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 403 Forbidden
< Server: openresty/1.9.7.3
< Date: Mon, 25 Feb 2019 12:56:25 GMT
< Content-Type: text/html
< Transfer-Encoding: chunked
< Connection: keep-alive
< 
<html><head><script type="text/javascript">location.replace("https://block.opendns.com/?url=7270777479158386166667808685&server=ams15&prefs=&tagging=&nref");</script></head></html>                                          

Очевидно, OpenDNS генерирует сертификат под конкретный домен на лету. Ну давайте и мы так делать.

Что такое openresty? Оказывается, OpenResty — это Nginx со вкусом Lua. Это живой форк Nginx со встроенным lua-nginx-module. Точнее даже просто поддержку Lua в Nginx вынесли в отдельный проект под названием OpenResty.

Среди всего прочего, что может Lua в Nginx, есть хук выбора сертификата в процессе TLS рукопожатия. Входными данными в этот момент является доменное имя сервера, передаваемое в SNI.

server {
    listen 0.0.0.0:3129 ssl;
    server_name _;

    ssl on;
    ssl_session_cache  builtin:1000  shared:SSL:10m;
    ssl_protocols  TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers HIGH:!aNULL:!eNULL:!EXPORT:!CAMELLIA:!DES:!MD5:!PSK:!RC4;
    ssl_prefer_server_ciphers on;

    ssl_certificate /etc/openresty/ssl/default.crt;
    ssl_certificate_key /etc/openresty/ssl/default.key;

    # вот тут подключается Lua скрипт
    ssl_certificate_by_lua_file /etc/openresty/certificate.lua;

    lua_need_request_body on;

    client_max_body_size 100k;
    client_body_buffer_size 100k;

    server_tokens off;

    root /usr/share/lib/html;
}
local ssl = require "ngx.ssl"

ssl.clear_certs()

local common_name = ssl.server_name()
if common_name == nil then
  common_name = "unknown"
end

-- ...

local priv_key
local cert_chain

-- ...

local ok, err = ssl.set_priv_key(priv_key)
if not ok then
  ngx.log(ngx.ERR, "failed to set priv key: ", err)
  return
end

local ok, err = ssl.set_cert(cert_chain)
if not ok then
  ngx.log(ngx.ERR, "failed to set cert: ", err)
  return
end

Есть даже статья в блоге, где описывается процесс генерации сертификата на лету прямо из Lua. Однако оказалось, что на стоковом OpenResty сделать этого нельзя. Автор блога самостоятельно дописал Lua модули, и никуда это не запулреквестил. А собирать OpenResty четырёхгодичной давности из исходников что-то не хочется.

В Lua, который в OpenResty, не хватает методов для генерации ключей и подписывания сертификатов. Прочитать и подсунуть сертификат можно. Ну давайте генерировать сертификаты где-то ещё. Скажем, на внешнем сервисе, куда будем ходить по HTTP.

Вроде как HTTP клиента в OpenResty не завезли. Хотя есть TCP клиент. Но в OpenResty можно поставить LuaRocks. Это такой менеджер пакетов для Lua. И в репозитории присутствуют некоторые дополнительные пакеты для OpenResty. Есть там и HTTP клиент. И даже Redis клиент.

Осталось сделать микросервис по генерации сертификатов. Конечно же на Go. В Go всё для этого есть.

func createCertificate(key *rsa.PrivateKey, commonName string, settings *Settings) (cert []byte, err error) {
    log.Println("Creating cert")
    serial, err := getSerialNumber()
    if err != nil {
        return
    }

    subject := getCertificateSubject(commonName)
    now := time.Now()

    template := x509.Certificate{
        SerialNumber: serial,
        Issuer:       caCertificates[0].Subject,
        Subject:      subject,
        DNSNames:     []string{commonName},

        NotBefore: now.Add(-time.Hour * time.Duration(settings.ValidBeforeHours)),
        NotAfter:  now.Add(time.Hour * time.Duration(settings.ValidAfterHours)),

        KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
        ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
        BasicConstraintsValid: true,
    }

    cert, err = x509.CreateCertificate(rand.Reader, &template, caCertificates[0], &key.PublicKey, caPrivateKey)
    return
}

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

X.509 — это как раз стандарт представления сертификатов, что за поля там могут быть.

Для сертификата веб сервера нужны именно такие KeyUsage и ExtKeyUsage, какие тут указаны. Эти флаги указывают, каким (и только таким) образом можно использовать данный сертификат.

Доменное имя, для которого мы создаём сертификат, должно присутствовать как CommonName (CN) часть Subject сертификата. А также доменные имена должны быть перечислены в Subject Alternative Name, в виде DNSNames в случае Go. Любопытно, что там можно и IP адреса указывать, и емейлы, и URI.

Раньше можно было без Alternative Name, обойтись только Subject. Но с недавних пор Chrome требует наличия Alternative Name.

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

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

Root CA (trusted)
|
+-- intermediate certificate
    |
    +-- domain certificate 

Тут тоже есть нюансы.

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

keyUsage = cRLSign, keyCertSign, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth

В браузер сымпортирован корневой сертификат. А у нас тут цепочка доверия длиной в три сертификата. Чтобы браузер смог восстановить всю цепочку, её всю должен предъявить сервер. Именно так. Сервер во время рукопожатия предъявляет три сертификата: конкретного домена, промежуточный и корневой. Так надо. Именно в таком порядке.

openssl s_client -connect localhost:3129 
CONNECTED(00000003)
depth=2 C = RU, ST = Omsk, L = Omsk, O = 7bits, CN = Root CA, emailAddress = aloha@7bits.it
verify error:num=19:self signed certificate in certificate chain
---
Certificate chain
 0 s:/C=RU/ST=Omsk/L=Omsk/O=7bits/OU=temporary/CN=localhost
   i:/C=RU/ST=Omsk/L=Omsk/O=7bits/OU=intermediate/CN=test.example.com/emailAddress=aloha@7bits.it
 1 s:/C=RU/ST=Omsk/L=Omsk/O=7bits/OU=intermediate/CN=test.example.com/emailAddress=aloha@7bits.it
   i:/C=RU/ST=Omsk/L=Omsk/O=7bits/CN=Root CA/emailAddress=aloha@7bits.it
 2 s:/C=RU/ST=Omsk/L=Omsk/O=7bits/CN=Root CA/emailAddress=aloha@7bits.it
   i:/C=RU/ST=Omsk/L=Omsk/O=7bits/CN=Root CA/emailAddress=aloha@7bits.it
---
...

В Linux сертификаты и ключи принято хранить в файлах .pem. PEM — это такой стандарт, где бинарные данные сертификатов и ключей, которые на самом деле закодированы в DER, представлены в base64. А в начале и конце блока ещё есть заголовки, описывающие его содержимое.

Сертификат может выглядеть так:

-----BEGIN CERTIFICATE-----
<тут несколько строчек в base64>
-----END CERTIFICATE-----

Это именно что сертификат X.509.

func readCaCertificates(files []string) (certs []*x509.Certificate, err error) {
    for _, fileName := range files {
        log.Printf("Reading certificate from %s\n", fileName)

        var file []byte
        file, err = ioutil.ReadFile(fileName)
        if err != nil {
            return
        }

        pemBlock, _ := pem.Decode(file)
        if pemBlock == nil {
            err = errors.New("failed to decode PEM")
            return
        }
        var cert *x509.Certificate
        cert, err = x509.ParseCertificate(pemBlock.Bytes)
        if err != nil {
            return
        }
        certs = append(certs, cert)
    }
    return
}

Приватный ключ, который генерирует EasyRsa, может выглядеть так:

-----BEGIN PRIVATE KEY-----
<тут несколько строчек в base64>
-----END PRIVATE KEY-----

Или так:

-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,<тут немного hex>

<тут несколько строчек в base64>
-----END RSA PRIVATE KEY-----

В первом случае это незашифрованный ключ в формате PKCS#8. Это может быть не только RSA ключ, какой именно, определяется уже внутри этих бинарных данных.

Во втором случае это зашифрованный (парольной фразой) RSA ключ в формате PKCS#1.

func readCaPrivateKey(path string, password string) (key *rsa.PrivateKey, err error) {
    log.Printf("Reading private key from %s\n", path)

    file, err := ioutil.ReadFile(path)
    if err != nil {
        return
    }
    pemBlock, _ := pem.Decode(file)
    if pemBlock == nil {
        err = errors.New("failed to decode PEM")
        return
    }

    var derKey []byte
    if x509.IsEncryptedPEMBlock(pemBlock) {
        derKey, err = x509.DecryptPEMBlock(pemBlock, []byte(password))
        if err != nil {
            return
        }
    } else {
        derKey = pemBlock.Bytes
    }

    switch pemBlock.Type {
    case "RSA PRIVATE KEY":
        key, err = x509.ParsePKCS1PrivateKey(derKey)
        if err != nil {
            return
        }
        return
    case "PRIVATE KEY":
        var someKey interface{}
        someKey, err = x509.ParsePKCS8PrivateKey(derKey)
        if err != nil {
            return
        }
        var ok bool
        key, ok = someKey.(*rsa.PrivateKey)
        if !ok {
            err = errors.New("not RSA key")
            return
        }
        return
    default:
        err = fmt.Errorf("unknown private key format: %s", pemBlock.Type)
        return
    }
}

.pem файлы хороши тем, что их можно конкатенировать. Собственно, все сертификаты цепочки доверия, которую должен вернуть сервер, можно записать в один файл. От конкретного сертификата сервера до доверенного корня. И этот файл вполне съест обычный Nginx.

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