Sigurne Go usluge: mTLS, rotacija ključeva i autorizacija s nultim povjerenjem u praksi
Usluga može šifrirati svaki bajt u prijenosu, a ipak vjerovati pogrešnom pozivatelju. Sigurnost u produkciji počinje kada se identitet prijenosa i autorizacija aplikacije tretiraju kao zasebne kontrole: uzajamni TLS dokazuje koji certifikat ravnopravni čvor posjeduje, dok autorizacija određuje što taj identitet smije učiniti.
Ovaj vodič izrađuje uslugu u Go 1.22 koja zahtijeva klijentske certifikate, autorizira točan URI identitet, ponovno učitava poslužiteljske certifikate i privatne ključeve bez prekida rada te se uredno gasi. Prateći klijent koristi isti model povjerenja i namjerno izbjegava nesigurne prečace kao što je InsecureSkipVerify.
Arhitektura i sigurnosne granice
Primjer ima jedno privatno tijelo za izdavanje certifikata, HTTPS poslužitelj na 127.0.0.1:8443 i klijenta identificiranog URI SAN-om spiffe://workshop.internal/service/catalog-client. URI nalikuje SPIFFE identifikatoru, ali ovaj vodič ne tvrdi usklađenost sa SPIFFE workload API-jem.
TLS sloj obavlja tri zadatka:
- Šifrira promet pomoću TLS-a 1.3.
- Provjerava predstavlja li poslužiteljski certifikat
localhost. - Zahtijeva klijentski certifikat koji je izdao konfigurirani CA.
Nakon rukovanja, HTTP posrednički sloj primjenjuje izričit popis dopuštenih. Certifikat ispravnog CA-a nužan je, ali nije dovoljan: autentificirano, ali neautorizirano radno opterećenje prima 403 Forbidden.
Privatni CA upotrijebljen u nastavku prikladan je za ograničenu vježbu, a ne za distribuciju produkcijskih vjerodajnica. Stvarna implementacija trebala bi koristiti interni CA, kratkotrajne certifikate radnih opterećenja, zaštićene ključeve za potpisivanje i automatizirani sustav izdavanja. Rotacija leaf certifikata i rotacija CA-a također su različite operacije. Rotacija leaf certifikata može biti trenutna; rotacija CA-a obično zahtijeva razdoblje preklapanja u kojem se vjeruje i starom i novom korijenu.
Preduvjeti i raspored projekta
Potrebni su vam Go 1.22 ili noviji, OpenSSL 3 i ljuska nalik Unixu. Usluga sluša na neprivilegiranom portu, pa ni kompilacija ni izvršavanje ne zahtijevaju root pristup.
secure-go-service/
├── go.mod
├── main.go
└── pki/
├── ca.crt
├── ca.key
├── server.crt
├── server.key
├── client.crt
└── client.key
Izradite novi radni direktorij, a zatim dodajte ovu datoteku modula:
module example.com/secure-go-service
go 1.22
Izdajte usko ograničene certifikate
Sljedeće naredbe stvaraju privatni CA i leaf certifikate s rokom od 30 dana. Pokrenite ih samo u novom direktoriju projekta: mkdir pki namjerno ne uspijeva ako ta putanja već postoji, čime se sprječava slučajna ponovna upotreba ili zamjena postojećeg PKI direktorija.
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
Poslužiteljski certifikat ima DNS i IP SAN-ove jer se provjera naziva hosta ne vraća na uobičajeni naziv. Identitet klijenta nalazi se u URI SAN-u, a ne u promjenjivom HTTP zaglavlju.
Implementirajte uslugu i klijenta
Smjestite sljedeći program u main.go. Ponovna učitavanja poslužitelja transakcijska su na razini procesa: datoteke se analiziraju i provjeravaju u novoj nepromjenjivoj konfiguraciji tls.Config, zatim se zamjenjuje atomski pokazivač. Neispravna zamjena ostavlja prethodnu konfiguraciju aktivnom.
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)
}
}
Svako TLS rukovanje prima konfiguraciju trenutačno pohranjenu u active. Postojeće veze ostaju šifrirane svojom dogovorenom sesijom dok se ne zatvore; rotacija utječe na nova rukovanja. Takvo je ponašanje poželjno jer prekidanje svake veze tijekom rutinske rotacije stvara prekide koji se mogu izbjeći.
Izradite i provjerite uspješan put
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
U drugom terminalu pokrenite klijenta:
./bin/secure-go-service \
-mode client \
-url https://localhost:8443/v1/inventory \
-cert pki/client.crt \
-key pki/client.key \
-ca pki/ca.crt
Odgovor bi trebao biti {"service":"inventory","status":"ready"}. Provjerite granicu prijenosa izostavljanjem klijentskog certifikata pomoću openssl s_client; rukovanje ne bi trebalo proizvesti upotrebljivu HTTP sesiju:
openssl s_client \
-connect 127.0.0.1:8443 \
-servername localhost \
-CAfile pki/ca.crt </dev/null
Također izdajte klijentski certifikat s drukčijim URI SAN-om i pokrenite klijenta s njim. TLS autentifikacija trebala bi uspjeti jer je CA pouzdan, ali HTTP zahtjev trebao bi vratiti 403 Forbidden. Ta je razlika važan regresijski test: dokazuje da autorizacija nije slučajno jednaka „potpisano našim CA-om”.
Rotirajte poslužiteljski ključ bez prekida rada
Izradite novi privatni ključ i certifikat u zasebnom direktoriju. Provjerite ih prije diranja aktivnih putanja.
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
Nakon provjere zadržite kopiju trenutačnog para koju je moguće oporaviti, instalirajte obje zamjene, a tek onda pošaljite SIGHUP. Ova premještanja zamjenjuju aktivne datoteke, stoga ih izvršite iz provjerenog direktorija projekta i upotrijebite jedinstveni direktorij sigurnosne kopije za svaku rotaciju.
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)"
Za implementaciju sa systemd-om prednost dajte naredbi systemctl reload secure-go s pravilom ExecReload umjesto otkrivanja procesa. Ako učitavanje ne uspije, zapisnik bilježi TLS reload rejected, a stara konfiguracija u memoriji nastavlja posluživati nove veze.
Klijent za jednokratno pokretanje čita svoj certifikat pri svakom izvršavanju, pa zamjena datoteka client.crt i client.key mijenja sljedeći zahtjev. Dugotrajni klijent trebao bi pri ponovnom učitavanju atomski zamijeniti svoj transport i pozvati CloseIdleConnections; u suprotnom postojeće skupne veze mogu nastaviti koristiti staru autentificiranu sesiju.
Učvršćivanje implementacije i rad
Pokrenite binarnu datoteku pod namjenskim neprivilegiranim računom. Račun treba pristup za čitanje vlastitom leaf ključu i CA paketu, ali nikada ne bi smio imati pristup CA ključu za potpisivanje. Držite ca.key izvan mreže ili u sustavu za izdavanje; ne pripada na host usluge.
Kompaktna systemd jedinica može nametnuti korisne granice:
[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
Osigurajte račun, binarnu datoteku, certifikate, vlasništvo i pravila vatrozida putem uobičajenog sustava za konfiguraciju. Dopustite port 8443 samo iz mreža radnih opterećenja kojima je potreban. mTLS ograničava tko može dovršiti rukovanje, ali segmentacija mreže i dalje smanjuje skeniranje, iscrpljivanje resursa i izloženost implementacijskim nedostacima.
Praćenje koje čuva tajne
Program generira strukturirane zapisnike zahtjeva, autorizacije, ponovnih učitavanja i TLS pogrešaka. Nemojte bilježiti certifikate, privatne ključeve, autorizacijska zaglavlja ni potpuna tijela zahtjeva. Izvezite brojače i histograme latencije za neuspjela rukovanja, odbijene autorizacije, neuspjela ponovna učitavanja, trajanje zahtjeva i aktivne veze.
Istek certifikata zaslužuje mjerač ili zakazano upozorenje temeljeno na NotAfter. Upozorenje o isteku trebalo bi se aktivirati znatno prije roka za rotaciju. Imajte na umu da HTTP posrednički sloj ne može pratiti neuspjela TLS rukovanja jer se ti neuspjesi događaju prije nego što HTTP zahtjev postoji; prikupljajte ih iz zapisnika pogrešaka poslužitelja ili prednjeg proxyja.
Razmatranja performansi
TLS rukovanja troše više CPU-a od ponovno korištenih HTTP veza, stoga omogućite keep-alive veze i izbjegavajte nepotrebna ponovna povezivanja. Suprotno tome, neograničeno trajanje veze odgađa primjenu certifikata i odgovor na opoziv. Postavite operativnu maksimalnu starost veze u proxyju ili klijentskom transportu kada je brzina rotacije važna.
Konfigurirana vremenska ograničenja za čitanje, pisanje, zaglavlje, uspostavljanje veze, rukovanje i ukupni zahtjev ograničavaju različite faze. Vremensko ograničenje za uspostavljanje veze ne ograničava TLS pregovaranje ni obradu odgovora. Mjerite prije podešavanja ponašanja šifri ili sesije; zadane postavke TLS-a 1.3 u Gou općenito su sigurnije od ručno održavanog popisa šifri.
Uobičajeni načini neuspjeha
- Nepodudaranje naziva hosta: povezivanje na naziv koji nije prisutan u SAN-u poslužitelja ne uspijeva čak i kada je CA koji ga je izdao pouzdan.
- Pogrešna proširena upotreba ključa: certifikat samo za poslužitelj ne može se koristiti kao klijentski certifikat i obrnuto.
- Pouzdan, ali neautoriziran: valjan certifikat s pogrešnim URI-jem dolazi do HTTP-a, ali prima
403. - Djelomična rotacija: zamjena samo ključa ili certifikata čini par neispravnim. Pripremite i provjerite oba prije slanja signala.
- Istekle veze prikrivaju rotaciju: uspostavljene keep-alive veze ne obavljaju novo rukovanje samo zato što su se datoteke promijenile.
- Zamjena korijena bez preklapanja: promjena izdavatelja prije nego što svaki ravnopravni čvor vjeruje novom CA-u uzrokuje prekid rada. Distribuirajte paket koji sadrži oba korijena, rotirajte leaf certifikate, a zatim uklonite stari korijen.
- Preširoko podudaranje identiteta: provjere podniza ili prefiksa mogu autorizirati nenamjerne identitete. Usporedite kanonski, točan URI i držite pravila odvojena od provjere lanca certifikata.
Završni kontrolni popis za provjeru
- Poslužitelj se pokreće s TLS-om 1.3 i odbija klijente bez pouzdanog certifikata.
- Očekivani URI identitet prima
200 OK; drugi identitet potpisan CA-om prima403 Forbidden. - Provjera naziva hosta uspijeva bez
InsecureSkipVerify. - Neispravne zamjenske datoteke proizvode pogrešku ponovnog učitavanja, dok prethodna konfiguracija ostaje aktivna.
- Provjereni novi par ključeva postaje vidljiv pri novim rukovanjima nakon
SIGHUP. - Privatne leaf ključeve može čitati samo račun usluge, a CA ključ za potpisivanje nije prisutan na hostu.
- Vremenska ograničenja, uredno gašenje, opseg vatrozida, upozorenja o isteku i zapisnici TLS pogrešaka konfigurirani su i testirani.
Nulto povjerenje nije prekidač proizvoda. Ono je disciplinirano sastavljanje uske dostupnosti mreže, kriptografskog identiteta, izričite autorizacije, kratkotrajnih vjerodajnica, vidljivog neuspjeha i sigurne rotacije. Kada svaka granica može sigurno odbiti pristup bez pretvaranja rutinskog održavanja ključeva u prekid rada, mTLS postaje operativna kontrola, a ne dekorativna enkripcija.