Реализация на Go

На дворе 2022 год, и небезопасные веб-сайты остались в прошлом. Большинство браузеров даже не позволят вам подключиться к сайту, обслуживаемому через чистый HTTP.

Однако это не изменило того факта, что работа с SSL-сертификатами может быть сложной. У вас есть несколько вариантов:

  • пойти традиционным путем, купив SSL-сертификат у одного из многих поставщиков
  • или получите его бесплатно от Let’s Encrypt

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

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

Это, однако, увеличивает тяжелый труд, так как следующее обновление всегда не за горами. И нередко вы можете обслуживать свои активы с более чем одного сервера, увеличивая объем ручной работы, необходимой каждый раз, когда срок действия сертификатов истекает.

К настоящему времени должно быть ясно, что любое взаимодействие с Let’s Encrypt должно быть автоматизировано. К счастью, они поддерживали этот поток с самого начала, используя протокол ACME (придуманный именно для этой цели).

Если вы используете стандартный HTTP-сервис, веб-сайт и т. д., вы найдете множество вариантов (инструментов) для решения этой проблемы:

  • certbot — эталонная реализация ACME
  • caddy — HTTP-сервер на базе Golang.
  • или десятки других реализаций на любом языке программирования, о котором только можно мечтать

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

А если нельзя!? Что, если вы можете открыть только один порт (и он не для HTTP) или если вы запускаете службу в изолированной среде, которая может только подключаться, но не может пропускать входящий трафик?

Введите вызов DNS-01

Согласно документации Let’s Encrypt:

в этом задании вам нужно доказать, что вы контролируете DNS для своего доменного имени, поместив определенное значение в запись TXT под этим доменным именем.

Я не мог сразу найти клиента, который поддерживал бы этот поток. Отчасти это связано с тем, что DNS — это протокол, а не API. В моем случае я управляю всеми своими доменами с помощью Cloudflare, поэтому мне понадобился клиент ACME, способный обновлять записи TXT в Cloudflare — не первая комбинация, которая приходит на ум…

Cloudflare — одна из многих платформ DNS-хостинга — стоимость создания и обслуживания библиотеки для большинства/всех таких платформ относительно высока — я понял, что должен создать свою собственную!

Напишите клиент ACME/Cloudflare, используя Go

В последнее время я изучаю Go. Это чрезвычайно популярный язык со здоровой экосистемой разработчиков. Мне нравится, что обычно легко найти различные библиотеки, которые могут решить большинство задач, например, для этой самой необходимости:

  • acmez: клиентская библиотека ACME для Go
  • libdns/cloudflare: провайдер DNS, использующий API Cloudflare.

Давайте начнем с простой структуры для хранения конфигурации нашего будущего сертификата SSL/TLS.

type Config struct {
 CertificatePrivateKeyPath string
 CloudflareAPIToken        string
}
  • ACMEAccountPrivateKeyPath: сначала необходимо зарегистрировать учетную запись в Let’s Encrypt; наш код сгенерирует ключ и сохранит его по этому пути
  • CertificatePrivateKeyPath: каждому сертификату нужен закрытый ключ; мы сгенерируем его и сохраним по этому пути
  • CertificatePath:мы будем хранить сертификат, полученный от Let’s Encrypt, по этому пути
  • CloudflareAPIToken: токен Cloudflare API, да!

Мы будем взаимодействовать с библиотекой через простой API:

func RequestCertificate(
  domains []string,
  ownerEmail []string,
  cfg *Config
) error {...}
// called as
domains := []string{"example.com"}
emails := []string{"mailto:[email protected]"}
err := RequestCertificate(domains, emails, c)
if err != nil {
  ...
}

Давайте приступим к реализации RequestCertificate :

ctx := context.Background()
// We use Uber's Zap logger, as required by acmez
logger, _ := zap.NewProduction()
defer logger.Sync() // flushes buffer, if any
// Initialize a DNS-01 solver, using Cloudflare APIs
solver := &certmagic.DNS01Solver{
  DNSProvider:        &cloudflare.Provider{APIToken: cfg.CloudflareAPIToken},
}
// The CA endpoint to use (prod or staging)
// switch to Production once fully tested
// otherwise you might get rate-limited in Production
// before you've had a chance to test that your client
// works as expected
caLocation = certmagic.LetsEncryptStagingCA
//caLocation := certmagic.LetsEncryptProductionCA
// CONTINUED BELOW ...

К вашему сведению, библиотека acmez использует Zap logger Uber. Вы можете не предоставлять регистратор, что означает, что вы не получите никакой информации от клиента ACME, упустив потенциальные ошибки.

// Initialize an acmez client
client := acmez.Client{
 Client: &acme.Client{
  Directory: caLocation,
  UserAgent: "[SOMETHING TO IDENTIFY YOUR CLIENT]",
  Logger:    logger,
 },
 ChallengeSolvers: map[string]acmez.Solver{
  acme.ChallengeTypeDNS01: solver,
 },
}
// Generate a private key for your Let's Encrypt account
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
 return fmt.Errorf("ecdsa.GenerateKey() could not generate an account key: %v", err)
}
// Create a Let's Encrypt account
account := acme.Account{
 Contact:              ownerEmail,
 TermsOfServiceAgreed: true,
 PrivateKey:           accountPrivateKey,
}
acc, _ := client.NewAccount(ctx, account)
if err != nil {
 return fmt.Errorf("client.NewAccount() could not create new account: %w", err)
}

На данный момент мы прошли аутентификацию с помощью Let’s Encrypt и готовы выдавать запросы на сертификаты.

// Generate a private key for the certificate
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
 return fmt.Errorf("generating certificate key: %w", err)
}
// TODO(left to the reader): store this key to a file// obtain certificates from Let's Encrypt
certs, err := client.ObtainCertificate(ctx, acc, certPrivateKey, domains)
if err != nil {
 return fmt.Errorf("client.ObtainCertificate() could not obtain certificate: %w", err)
}
// since the client returns more than one cert, it is up to you
// to choose the most appropriate one (such as one which contains
// the full chain, including any intermediate certificates)
for _, cert := range certs {
 log.Println(string(cert.ChainPEM))
 // TODO(left to the reader): store cert.ChainPEM to a file
}
return nil

Один совет, который я могу вам дать, заключается в том, что для хранения закрытого ключа вы должны сначала преобразовать его в форму ASN.1 DER. Это легко достигается; см. ниже:

func EncodeAndStorePrivateKey(privateKey *ecdsa.PrivateKey, filename string, mode fs.FileMode) error {
 x509Encoded, err := x509.MarshalECPrivateKey(privateKey)
 if err != nil {
  return err
 }
data := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509Encoded})
 return os.WriteFile(filename, data, mode)
}

И это почти все! Теперь вы можете выпускать и обновлять SSL/TLS-сертификаты с помощью Let’s Encrypt, используя вызовы DNS-01 (если DNS ваших доменов управляется в Cloudflare.

Еще кое-что; вот полный список импорта Go, который нужно добавить — это сэкономит вам немного времени при объединении всего этого!

import (
 "context"
 "crypto/ecdsa"
 "crypto/elliptic"
 "crypto/rand"
 "crypto/x509"
 "encoding/pem"
 "fmt"
 "io/fs"
 "log"
 "os"
 "github.com/caddyserver/certmagic"
 "github.com/libdns/cloudflare"
 "github.com/mholt/acmez"
 "github.com/mholt/acmez/acme"
 "go.uber.org/zap"
)

Не забудьте go get внешние модули, перечисленные выше!

Спасибо за прочтение. Дайте мне знать, что вы думаете, в Твиттере!