Безбедни Go услуги: mTLS, ротација на клучеви и авторизација со нулта доверба во практика
Услугата може да го шифрира секој бајт во преносот, а сепак да му верува на погрешниот повикувач. Безбедноста во продукција започнува кога транспортниот идентитет и авторизацијата на апликацијата се третираат како одделни контроли: меѓусебниот TLS докажува кој сертификат го поседува другиот крај, додека авторизацијата одлучува што смее да прави тој идентитет.
Овој туторијал создава Go 1.22 услуга што бара клиентски сертификати, авторизира точен URI идентитет, повторно вчитува серверски сертификати и приватни клучеви без прекин и се исклучува уредно. Придружниот клиент го користи истиот модел на доверба и намерно избегнува небезбедни кратенки како InsecureSkipVerify.
Архитектура и безбедносни граници
Примерот има еден приватен издавач на сертификати, HTTPS сервер на 127.0.0.1:8443 и клиент идентификуван со URI SAN spiffe://workshop.internal/service/catalog-client. URI наликува на SPIFFE идентификатор, но овој туторијал не тврди усогласеност со SPIFFE workload API.
TLS слојот извршува три задачи:
- Го шифрира сообраќајот со TLS 1.3.
- Проверува дали серверскиот сертификат го претставува
localhost. - Бара клиентски сертификат издаден од конфигурираниот CA.
По ракувањето, HTTP посредникот применува експлицитна листа на дозволени. Сертификат од точниот CA е неопходен, но не и доволен: автентицирано, но неавторизирано работно оптоварување добива 403 Forbidden.
Приватниот CA што се користи подолу е погоден за ограничена вежба, не за дистрибуирање продукциски ингеренции. Вистинско распоредување треба да користи интерен CA, краткотрајни сертификати за работни оптоварувања, заштитени клучеви за потпишување и автоматизиран систем за издавање. Ротацијата на листови и ротацијата на CA се исто така различни операции. Ротацијата на листови може да биде непосредна; ротацијата на CA обично бара период на преклопување во кој и старите и новите коренски сертификати се доверливи.
Предуслови и распоред на проектот
Ви требаат Go 1.22 или понов, OpenSSL 3 и Unix-слична школка. Услугата слуша на непривилегиран порт, така што ниту компилацијата ниту извршувањето не бараат root пристап.
secure-go-service/
├── go.mod
├── main.go
└── pki/
├── ca.crt
├── ca.key
├── server.crt
├── server.key
├── client.crt
└── client.key
Создадете нов работен директориум, потоа додајте ја оваа модулска датотека:
module example.com/secure-go-service
go 1.22
Издадете тесно ограничени сертификати
Следните команди создаваат приватен CA и лист-сертификати со важност од 30 дена. Извршете ги само во нов проектен директориум: mkdir pki намерно не успева ако таа патека веќе постои, спречувајќи случајно повторно користење или замена на постоечки PKI директориум.
umask 077
mkdir -m 0700 pki
openssl genpkey -algorithm EC \
-pkeyopt ec_paramgen_curve:P-256 \
-out pki/ca.key
openssl req -x509 -new -sha256 -days 3650 \
-key pki/ca.key \
-subj "/CN=Workshop Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out pki/ca.crt
openssl req -new -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout pki/server.key \
-subj "/CN=inventory.internal" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=serverAuth" \
-out pki/server.csr
openssl x509 -req -sha256 -days 30 \
-in pki/server.csr \
-CA pki/ca.crt -CAkey pki/ca.key -CAcreateserial \
-copy_extensions copy \
-out pki/server.crt
openssl req -new -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout pki/client.key \
-subj "/CN=catalog-client" \
-addext "subjectAltName=URI:spiffe://workshop.internal/service/catalog-client" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=clientAuth" \
-out pki/client.csr
openssl x509 -req -sha256 -days 30 \
-in pki/client.csr \
-CA pki/ca.crt -CAkey pki/ca.key -CAserial pki/ca.srl \
-copy_extensions copy \
-out pki/client.crt
openssl verify -CAfile pki/ca.crt -purpose sslserver pki/server.crt
openssl verify -CAfile pki/ca.crt -purpose sslclient pki/client.crt
rm pki/server.csr pki/client.csr
Сертификатот на серверот има DNS и IP SAN бидејќи проверката на името на домаќинот не се враќа на заедничкото име. Идентитетот на клиентот се наоѓа во URI SAN, наместо во променливо HTTP заглавие.
Имплементирајте ги услугата и клиентот
Поставете ја следната програма во main.go. Серверските повторни вчитувања се трансакциски на ниво на процес: датотеките се анализираат и валидираат во нова непроменлива tls.Config, потоа се заменува атомски покажувач. Неправилна замена ја остава претходната конфигурација активна.
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"sync/atomic"
"syscall"
"time"
)
const allowedIdentity = "spiffe://workshop.internal/service/catalog-client"
type identityKey struct{}
func loadServerTLS(certFile, keyFile, caFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load server key pair: %w", err)
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read client CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("client CA contains no certificates")
}
return &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool,
}, nil
}
func loadClientTLS(certFile, keyFile, caFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load client key pair: %w", err)
}
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read server CA: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, errors.New("server CA contains no certificates")
}
return &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{cert},
RootCAs: pool,
}, nil
}
func authorize(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
http.Error(w, "client certificate required", http.StatusUnauthorized)
return
}
identity := ""
for _, uri := range r.TLS.PeerCertificates[0].URIs {
if uri.String() == allowedIdentity {
identity = uri.String()
break
}
}
if identity == "" {
logger.Warn("authorization denied",
"remote", r.RemoteAddr, "path", r.URL.Path)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), identityKey{}, identity)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func observe(next http.Handler, logger *slog.Logger) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request completed",
"method", r.Method,
"path", r.URL.Path,
"identity", r.Context().Value(identityKey{}),
"duration_ms", time.Since(start).Milliseconds())
})
}
func runServer(ctx context.Context, addr, certFile, keyFile, caFile string) error {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
initial, err := loadServerTLS(certFile, keyFile, caFile)
if err != nil {
return err
}
var active atomic.Pointer[tls.Config]
active.Store(initial)
dispatcher := &tls.Config{
MinVersion: tls.VersionTLS13,
GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
return active.Load(), nil
},
}
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/inventory", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, `{"service":"inventory","status":"ready"}`+"\n")
})
server := &http.Server{
Addr: addr,
Handler: authorize(observe(mux, logger), logger),
TLSConfig: dispatcher,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 1 << 20,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn),
}
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go func() {
for {
select {
case <-ctx.Done():
return
case <-hup:
replacement, err := loadServerTLS(certFile, keyFile, caFile)
if err != nil {
logger.Error("TLS reload rejected", "error", err)
continue
}
active.Store(replacement)
logger.Info("TLS configuration reloaded")
}
}
}()
errCh := make(chan error, 1)
go func() {
logger.Info("server starting", "address", addr)
errCh <- server.ListenAndServeTLS("", "")
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
err := <-errCh
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func runClient(url, certFile, keyFile, caFile string) error {
tlsConfig, err := loadClientTLS(certFile, keyFile, caFile)
if err != nil {
return err
}
transport := &http.Transport{
TLSClientConfig: tlsConfig,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
IdleConnTimeout: 30 * time.Second,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("HTTP %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
fmt.Print(string(body))
return nil
}
func main() {
mode := flag.String("mode", "", "server or client")
addr := flag.String("listen", "127.0.0.1:8443", "server listen address")
url := flag.String("url", "https://localhost:8443/v1/inventory", "client URL")
cert := flag.String("cert", "", "leaf certificate path")
key := flag.String("key", "", "private key path")
ca := flag.String("ca", "", "CA bundle path")
flag.Parse()
if *cert == "" || *key == "" || *ca == "" {
fmt.Fprintln(os.Stderr, "-cert, -key, and -ca are required")
os.Exit(2)
}
ctx, stop := signal.NotifyContext(
context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
var err error
switch *mode {
case "server":
err = runServer(ctx, *addr, *cert, *key, *ca)
case "client":
err = runClient(*url, *cert, *key, *ca)
default:
err = errors.New("-mode must be server or client")
}
if err != nil {
slog.Error("exiting", "error", err)
os.Exit(1)
}
}
Секое TLS ракување ја добива конфигурацијата што моментално е зачувана во active. Постоечките врски остануваат шифрирани со договорената сесија додека не се затворат; ротацијата влијае на новите ракувања. Таквото однесување е пожелно бидејќи прекинувањето на секоја врска при рутинска ротација создава непотребен прекин.
Изградете и проверете ја успешната патека
mkdir -m 0755 bin
go build -trimpath -o bin/secure-go-service .
./bin/secure-go-service \
-mode server \
-listen 127.0.0.1:8443 \
-cert pki/server.crt \
-key pki/server.key \
-ca pki/ca.crt
Во втор терминал, извршете го клиентот:
./bin/secure-go-service \
-mode client \
-url https://localhost:8443/v1/inventory \
-cert pki/client.crt \
-key pki/client.key \
-ca pki/ca.crt
Одговорот треба да биде {"service":"inventory","status":"ready"}. Тестирајте ја транспортната граница со изоставување на клиентскиот сертификат преку openssl s_client; ракувањето не треба да создаде употреблива HTTP сесија:
openssl s_client \
-connect 127.0.0.1:8443 \
-servername localhost \
-CAfile pki/ca.crt </dev/null
Исто така, издадете клиентски сертификат со различен URI SAN и извршете го клиентот со него. TLS автентикацијата треба да успее бидејќи CA е доверлив, но HTTP барањето треба да врати 403 Forbidden. Оваа разлика е важен регресиски тест: докажува дека авторизацијата случајно не е изедначена со „потпишано од нашиот CA“.
Ротирајте го серверскиот клуч без прекин
Создадете нов приватен клуч и сертификат во посебен директориум. Валидирајте ги пред да ги допрете активните патеки.
umask 077
mkdir -m 0700 pki/rotation-1
openssl req -new -newkey ec \
-pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout pki/rotation-1/server.key \
-subj "/CN=inventory.internal" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "keyUsage=critical,digitalSignature" \
-addext "extendedKeyUsage=serverAuth" \
-out pki/rotation-1/server.csr
openssl x509 -req -sha256 -days 30 \
-in pki/rotation-1/server.csr \
-CA pki/ca.crt -CAkey pki/ca.key -CAserial pki/ca.srl \
-copy_extensions copy \
-out pki/rotation-1/server.crt
openssl verify -CAfile pki/ca.crt \
-purpose sslserver pki/rotation-1/server.crt
openssl x509 -checkend 300 -noout \
-in pki/rotation-1/server.crt
По валидацијата, задржете копија од тековниот пар што може да се врати, инсталирајте ги двете замени, па дури потоа испратете SIGHUP. Овие преместувања ги заменуваат активните датотеки, затоа извршете ги од потврдениот проектен директориум и користете единствен резервен директориум за секоја ротација.
mkdir -m 0700 pki/backup-rotation-1
cp -p pki/server.crt pki/server.key pki/backup-rotation-1/
install -m 0600 pki/rotation-1/server.key pki/server.key.new
install -m 0600 pki/rotation-1/server.crt pki/server.crt.new
mv pki/server.key.new pki/server.key
mv pki/server.crt.new pki/server.crt
kill -HUP "$(pgrep -n -x secure-go-service)"
За распоредување со systemd, претпочитајте systemctl reload secure-go со правило ExecReload наместо откривање процеси. Ако вчитувањето не успее, дневникот бележи TLS reload rejected и старата конфигурација во меморијата продолжува да опслужува нови врски.
Еднократниот клиент го чита својот сертификат при секое извршување, па замената на client.crt и client.key го менува следното барање. Долготраен клиент треба атомски да го замени својот транспорт при повторно вчитување и да повика CloseIdleConnections; во спротивно, постоечките здружени врски може да продолжат да ја користат старата автентицирана сесија.
Зацврстување на распоредувањето и операции
Извршувајте ја бинарната датотека како посебна непривилегирана сметка. Сметката има потреба од пристап за читање до својот лист-клуч и CA пакет, но никогаш не треба да има пристап до клучот за потпишување на CA. Чувајте го ca.key офлајн или во системот за издавање; не припаѓа на хостот на услугата.
Компактна systemd единица може да наметне корисни граници:
[Unit]
Description=Secure Go inventory service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=securego
Group=securego
ExecStart=/opt/secure-go/bin/secure-go-service -mode server -listen 0.0.0.0:8443 -cert /etc/secure-go/server.crt -key /etc/secure-go/server.key -ca /etc/secure-go/client-ca.crt
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
ReadOnlyPaths=/etc/secure-go
[Install]
WantedBy=multi-user.target
Обезбедете ја сметката, бинарната датотека, сертификатите, сопственоста и политиката на заштитниот ѕид преку вашиот вообичаен конфигурациски систем. Дозволете порт 8443 само од мрежите на работните оптоварувања на кои им е потребен. mTLS ограничува кој може да заврши ракување, но мрежната сегментација сè уште ги намалува скенирањето, исцрпувањето ресурси и изложеноста на имплементациски дефекти.
Набљудливост што ги зачувува тајните
Програмата емитува структурирани дневници за барања, авторизација, повторно вчитување и TLS грешки. Не запишувајте сертификати, приватни клучеви, заглавија за авторизација или целосни тела на барања во дневник. Извезете бројачи и хистограми на латентност за неуспеси на ракување, одбивања на авторизација, неуспеси при повторно вчитување, времетраење на барањата и активни врски.
Истекувањето на сертификатите заслужува метрика или закажано предупредување врз основа на NotAfter. Предупредувањето за истекување треба да се активира многу пред рокот за ротација. Запомнете дека HTTP посредникот не може да набљудува неуспешни TLS ракувања бидејќи тие неуспеси се случуваат пред да постои HTTP барање; собирајте ги од дневникот за грешки на серверот или од прокси пред серверот.
Размислувања за перформанси
TLS ракувањата трошат повеќе CPU од повторно искористените HTTP врски, затоа овозможете keep-alive и избегнувајте непотребни повторни поврзувања. Обратно, неограничениот животен век на врските го одложува прифаќањето сертификати и одговорот на отповикување. Поставете оперативна максимална старост на врска во прокси или клиентски транспорт кога брзината на ротација е важна.
Конфигурираните временски ограничувања за читање, запишување, заглавие, бирање, ракување и целокупно барање ограничуваат различни фази. Временското ограничување за бирање не ги ограничува TLS преговорите или обработката на одговорот. Мерете пред да подесувате однесување на шифри или сесии; стандардните поставки за TLS 1.3 во Go генерално се побезбедни од рачно одржувана листа на шифри.
Чести начини на неуспех
- Несовпаѓање на име на домаќин: поврзувањето со име што го нема во серверскиот SAN не успева дури и кога издавачкиот CA е доверлив.
- Погрешна проширена употреба на клуч: сертификат само за сервер не може да се користи како клиентски сертификат, и обратно.
- Доверлив, но неавторизиран: важечки сертификат со погрешен URI стигнува до HTTP, но добива
403. - Делумна ротација: заменувањето само на клучот или сертификатот го прави парот неважечки. Подгответе ги и валидирајте ги двата пред сигнализирање.
- Истечени врски што ја прикриваат ротацијата: воспоставените keep-alive врски не вршат ново ракување само затоа што датотеките се променети.
- Замена на корен без преклопување: менувањето издавачи пред секој врсник да му верува на новиот CA предизвикува прекин. Дистрибуирајте пакет што ги содржи двата корена, ротирајте ги листовите, па отстранете го стариот корен.
- Премногу широко совпаѓање на идентитет: проверки со подниза или префикс можат да авторизираат ненамерни идентитети. Споредете канонски, точен URI и држете ја политиката одделно од валидацијата на синџирот на сертификати.
Конечна контролна листа за проверка
- Серверот стартува со TLS 1.3 и одбива клиенти без доверлив сертификат.
- Очекуваниот URI идентитет добива
200 OK; друг идентитет потпишан од CA добива403 Forbidden. - Проверката на името на домаќинот успева без
InsecureSkipVerify. - Неправилните заменски датотеки предизвикуваат грешка при повторно вчитување, додека претходната конфигурација останува активна.
- Валидиран нов пар клучеви станува видлив при новите ракувања по
SIGHUP. - Приватните лист-клучеви се читливи само од сметката на услугата, а клучот за потпишување на CA не е присутен на хостот.
- Временските ограничувања, уредното исклучување, опсегот на заштитниот ѕид, предупредувањата за истекување и дневниците за TLS неуспеси се конфигурирани и тестирани.
Нултата доверба не е прекинувач на производ. Таа е дисциплинирана комбинација од тесна мрежна достапност, криптографски идентитет, експлицитна авторизација, краткотрајни ингеренции, набљудлив неуспех и безбедна ротација. Кога секоја граница може да не успее затворено без рутинското одржување на клучевите да го претвори во прекин, mTLS станува оперативна контрола наместо декоративно шифрирање.