burterm/internal/proxy/ca.go
2026-09-14 10:55:07 +03:00

178 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package proxy
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"path/filepath"
"sync"
"time"
)
// CA — локальный корневой сертификат для перехвата HTTPS-трафика (MITM),
// по аналогии с Burp CA / mitmproxy CA. Генерируется один раз и
// сохраняется на диск; чтобы браузер перестал ругаться на HTTPS-сайты,
// ca.pem нужно один раз импортировать в доверенные корневые сертификаты
// браузера/ОС.
type CA struct {
cert *x509.Certificate
key *rsa.PrivateKey
leafCache sync.Map // host string -> *tls.Certificate
}
// CADir возвращает директорию для хранения CA (~/.burterm).
func CADir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("не удалось определить домашнюю директорию: %w", err)
}
return filepath.Join(home, ".burterm"), nil
}
// LoadOrCreateCA загружает CA из dir/ca.pem + dir/ca-key.pem, либо создаёт
// новый и сохраняет туда же, если файлов ещё нет.
func LoadOrCreateCA(dir string) (*CA, error) {
certPath := filepath.Join(dir, "ca.pem")
keyPath := filepath.Join(dir, "ca-key.pem")
certBytes, err := os.ReadFile(certPath)
if err == nil {
keyBytes, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("найден ca.pem, но не найден ca-key.pem рядом: %w", err)
}
return parseCA(certBytes, keyBytes)
}
return createCA(dir, certPath, keyPath)
}
func createCA(dir, certPath, keyPath string) (*CA, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("не удалось создать %s: %w", dir, err)
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("генерация ключа CA: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("генерация серийного номера CA: %w", err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "burterm MITM CA",
Organization: []string{"burterm"},
},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("создание сертификата CA: %w", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, fmt.Errorf("сохранение ca.pem: %w", err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, fmt.Errorf("сохранение ca-key.pem: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("разбор свежесозданного сертификата CA: %w", err)
}
return &CA{cert: cert, key: key}, nil
}
func parseCA(certPEMBytes, keyPEMBytes []byte) (*CA, error) {
certBlock, _ := pem.Decode(certPEMBytes)
if certBlock == nil {
return nil, fmt.Errorf("ca.pem повреждён или пуст")
}
cert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("разбор ca.pem: %w", err)
}
keyBlock, _ := pem.Decode(keyPEMBytes)
if keyBlock == nil {
return nil, fmt.Errorf("ca-key.pem повреждён или пуст")
}
key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("разбор ca-key.pem: %w", err)
}
return &CA{cert: cert, key: key}, nil
}
// LeafFor возвращает (создавая и кэшируя при первом обращении) листовой
// TLS-сертификат для host, подписанный этим CA. Используется в
// tls.Config.GetCertificate при перехвате CONNECT-туннеля — по сертификату
// на каждый домен, с которым говорит браузер через прокси.
func (ca *CA) LeafFor(host string) (*tls.Certificate, error) {
if cached, ok := ca.leafCache.Load(host); ok {
return cached.(*tls.Certificate), nil
}
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("генерация ключа для %s: %w", host, err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, fmt.Errorf("генерация серийного номера для %s: %w", host, err)
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: host},
DNSNames: []string{host},
NotBefore: time.Now().Add(-24 * time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, &key.PublicKey, ca.key)
if err != nil {
return nil, fmt.Errorf("подпись сертификата для %s: %w", host, err)
}
leaf := &tls.Certificate{
Certificate: [][]byte{der, ca.cert.Raw},
PrivateKey: key,
}
actual, _ := ca.leafCache.LoadOrStore(host, leaf)
return actual.(*tls.Certificate), nil
}
// CertPath — путь к ca.pem, который нужно один раз импортировать
// в доверенные корневые сертификаты браузера/ОС, иначе HTTPS-сайты
// будут показывать предупреждение о недоверенном сертификате.
func (ca *CA) CertPath(dir string) string {
return filepath.Join(dir, "ca.pem")
}