From 3fcd8f92af12af9c1ba6625916b636267490489e Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 09:32:51 +0200 Subject: [PATCH] API-09: mtls-zwischen-modul-instanzen-verteilte-installation (interne ca, rotation ohne ausfallzeit, stufe-1-optout) --- internal/mtls/ca.go | 158 +++++++++++++++++++++++++++++++ internal/mtls/config.go | 46 +++++++++ internal/mtls/mtls_test.go | 185 +++++++++++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+) create mode 100644 internal/mtls/ca.go create mode 100644 internal/mtls/config.go create mode 100644 internal/mtls/mtls_test.go diff --git a/internal/mtls/ca.go b/internal/mtls/ca.go new file mode 100644 index 0000000..08972fc --- /dev/null +++ b/internal/mtls/ca.go @@ -0,0 +1,158 @@ +// Package mtls implementiert Core API-09: gegenseitige TLS-Authentifizierung +// zwischen Core/DMS/Mail/Archive/Workflow/AI/Connect-Instanzen fuer +// Stufe-2/3-Installationen (siehe SKALIERUNGSKONZEPT.md) — schuetzt die +// VERTRAULICHKEIT des Transportwegs, dort wo API-05 (asymmetrische +// JWT-Signatur) und API-02 (Service-Credential) nur Authentizitaet/ +// Integritaet, aber keine Transportverschluesselung zwischen getrennten +// Hosts absichern. +// +// Zertifikatsverteilung (Akzeptanzkriterium 1): Authority.IssueCert liefert +// PEM-kodiertes Zertifikat + privaten Schluessel, die ueber denselben Weg +// wie andere Secrets verteilt werden (Umgebungsvariablen/Secret-Store, +// niemals im Code) — automatisierbar, da IssueCert ein reiner +// Funktionsaufruf ohne manuelle Schritte ist. +package mtls + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "time" +) + +// Authority ist eine interne Zertifizierungsstelle fuer Modul-zu-Modul- +// mTLS. Haelt — analog zu internal/moduletrust.KeyManager (API-05) — ALLE +// noch gueltigen historischen CA-Zertifikate im Vertrauensspeicher, damit +// eine Rotation bereits ausgestellte Leaf-Zertifikate nicht ungueltig macht +// (Akzeptanzkriterium 3: Rotation ohne Ausfallzeit). +type Authority struct { + current caGeneration + trustPool *x509.CertPool + generations []caGeneration +} + +type caGeneration struct { + cert *x509.Certificate + key ed25519.PrivateKey +} + +// NewAuthority erzeugt eine frische interne CA. +func NewAuthority() (*Authority, error) { + gen, err := newCAGeneration() + if err != nil { + return nil, err + } + pool := x509.NewCertPool() + pool.AddCert(gen.cert) + return &Authority{current: gen, trustPool: pool, generations: []caGeneration{gen}}, nil +} + +func newCAGeneration() (caGeneration, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return caGeneration{}, fmt.Errorf("ca-schluesselpaar erzeugen: %w", err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + return caGeneration{}, fmt.Errorf("seriennummer erzeugen: %w", err) + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "nexarch-internal-ca"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(5 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv) + if err != nil { + return caGeneration{}, fmt.Errorf("ca-zertifikat erstellen: %w", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + return caGeneration{}, fmt.Errorf("ca-zertifikat parsen: %w", err) + } + return caGeneration{cert: cert, key: priv}, nil +} + +// Rotate erzeugt eine NEUE CA-Generation fuer zukuenftige IssueCert-Aufrufe, +// behaelt aber ALLE bisherigen CA-Zertifikate im Vertrauensspeicher — +// bereits ausgestellte Leaf-Zertifikate bleiben dadurch gueltig, eine +// laufende mTLS-Verbindung wird durch Rotate NICHT unterbrochen +// (Akzeptanzkriterium 3 / Pruefung 2). +func (a *Authority) Rotate() error { + gen, err := newCAGeneration() + if err != nil { + return err + } + a.trustPool.AddCert(gen.cert) + a.generations = append(a.generations, gen) + a.current = gen + return nil +} + +// TrustPool liefert den Vertrauensspeicher mit ALLEN (auch historischen, +// noch nicht abgelaufenen) CA-Zertifikaten — Grundlage der +// Server-seitigen Client-Zertifikatspruefung (Akzeptanzkriterium 2). +func (a *Authority) TrustPool() *x509.CertPool { + return a.trustPool +} + +// IssuedCert ist ein ausgestelltes Leaf-Zertifikat inklusive privatem +// Schluessel, PEM-kodiert zur Verteilung (Akzeptanzkriterium 1). +type IssuedCert struct { + CertPEM []byte + KeyPEM []byte +} + +// IssueCert stellt ein Leaf-Zertifikat fuer EINE Modul-Instanz aus, signiert +// mit der AKTUELLEN CA-Generation (Akzeptanzkriterium 1). +func (a *Authority) IssueCert(commonName string, validity time.Duration) (IssuedCert, error) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return IssuedCert{}, fmt.Errorf("leaf-schluesselpaar erzeugen: %w", err) + } + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + return IssuedCert{}, fmt.Errorf("seriennummer erzeugen: %w", err) + } + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: commonName}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(validity), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + // DNSNames/IPAddresses decken lokale Testumgebungen ab (127.0.0.1, + // localhost) — in echten Stufe-2/3-Installationen entspricht + // commonName dem tatsaechlichen internen Hostnamen der Instanz. + DNSNames: []string{commonName, "localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + der, err := x509.CreateCertificate(rand.Reader, template, a.current.cert, pub, a.current.key) + if err != nil { + return IssuedCert{}, fmt.Errorf("leaf-zertifikat erstellen: %w", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return IssuedCert{}, fmt.Errorf("leaf-schluessel serialisieren: %w", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) + + return IssuedCert{CertPEM: certPEM, KeyPEM: keyPEM}, nil +} + +// TLSCertificate wandelt ein IssuedCert in ein tls.Certificate um, wie es +// tls.Config.Certificates erwartet. +func (c IssuedCert) TLSCertificate() (tls.Certificate, error) { + return tls.X509KeyPair(c.CertPEM, c.KeyPEM) +} diff --git a/internal/mtls/config.go b/internal/mtls/config.go new file mode 100644 index 0000000..2774ee0 --- /dev/null +++ b/internal/mtls/config.go @@ -0,0 +1,46 @@ +package mtls + +import "crypto/tls" + +// Mode legt fest, ob mTLS erzwungen wird. Fuer Stufe-1-Einzel-LXC ist mTLS +// laut Ticket "optional/nicht zwingend" (lokale Kommunikation) — ModeOff +// liefert einen ganz normalen TLS-Server OHNE Client-Zertifikatspruefung, +// damit Stufe-1-Betrieb unveraendert weiterlaeuft (Akzeptanzkriterium 3 / +// Pruefung 3). +type Mode string + +const ( + ModeOff Mode = "off" // Stufe 1: kein mTLS-Zwang + ModeRequired Mode = "required" // Stufe 2/3: Client-Zertifikat zwingend +) + +// ServerTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die +// eingehende Verbindungen ANDERER Modul-Instanzen annimmt. +// +// - ModeRequired: verlangt UND verifiziert ein Client-Zertifikat gegen +// authority.TrustPool() (Akzeptanzkriterium 2) — eine Verbindung ohne +// gueltiges Zertifikat wird vom TLS-Handshake selbst abgelehnt, bevor +// irgendein Anwendungscode erreicht wird. +// - ModeOff: normales TLS ohne Client-Zertifikatspruefung +// (Akzeptanzkriterium 3 / Pruefung 3: Stufe-1-Betrieb funktioniert +// weiterhin ohne mTLS-Zwang). +func ServerTLSConfig(mode Mode, serverCert tls.Certificate, authority *Authority) *tls.Config { + cfg := &tls.Config{Certificates: []tls.Certificate{serverCert}} + if mode == ModeRequired { + cfg.ClientAuth = tls.RequireAndVerifyClientCert + cfg.ClientCAs = authority.TrustPool() + } + return cfg +} + +// ClientTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die eine +// Verbindung zu einer ANDEREN Modul-Instanz aufbaut — praesentiert das +// eigene Zertifikat und vertraut Gegenstellen, die von derselben Authority +// signiert wurden. +func ClientTLSConfig(mode Mode, clientCert tls.Certificate, authority *Authority) *tls.Config { + cfg := &tls.Config{RootCAs: authority.TrustPool()} + if mode == ModeRequired { + cfg.Certificates = []tls.Certificate{clientCert} + } + return cfg +} diff --git a/internal/mtls/mtls_test.go b/internal/mtls/mtls_test.go new file mode 100644 index 0000000..06ae819 --- /dev/null +++ b/internal/mtls/mtls_test.go @@ -0,0 +1,185 @@ +package mtls + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func newTLSServer(t *testing.T, cfg *tls.Config) *httptest.Server { + t.Helper() + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + server.TLS = cfg + server.StartTLS() + return server +} + +func fetch(t *testing.T, url string, clientCfg *tls.Config) (int, error) { + t.Helper() + client := &http.Client{ + Transport: &http.Transport{TLSClientConfig: clientCfg}, + Timeout: 3 * time.Second, + } + resp, err := client.Get(url) + if err != nil { + return 0, err + } + defer resp.Body.Close() + _, _ = io.ReadAll(resp.Body) + return resp.StatusCode, nil +} + +// Akzeptanzkriterium 1: Zertifikatsverteilung ist ein reiner +// Funktionsaufruf, das Ergebnis ist gueltiges, wiederverwendbares +// PEM-Material. +func TestIssueCert_ProducesValidPEMRoundTrip(t *testing.T) { + authority, err := NewAuthority() + if err != nil { + t.Fatalf("newauthority: %v", err) + } + issued, err := authority.IssueCert("dms", time.Hour) + if err != nil { + t.Fatalf("issuecert: %v", err) + } + if _, err := issued.TLSCertificate(); err != nil { + t.Fatalf("ausgestelltes zertifikat nicht als tls.Certificate ladbar: %v", err) + } +} + +// Akzeptanzkriterium 2 + Pruefung 1: eine Verbindung ohne gueltiges +// Client-Zertifikat wird abgelehnt, eine mit gueltigem angenommen. +func TestServerTLSConfig_RejectsMissingClientCert(t *testing.T) { + authority, err := NewAuthority() + if err != nil { + t.Fatalf("newauthority: %v", err) + } + serverIssued, err := authority.IssueCert("core", time.Hour) + if err != nil { + t.Fatalf("server-zertifikat ausstellen: %v", err) + } + serverCert, err := serverIssued.TLSCertificate() + if err != nil { + t.Fatalf("server-zertifikat laden: %v", err) + } + + server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority)) + defer server.Close() + + // Client OHNE Zertifikat, aber mit korrektem RootCA-Vertrauen fuer den + // Server — der Handshake muss trotzdem an der fehlenden Client-Auth + // scheitern. + _, err = fetch(t, server.URL, &tls.Config{RootCAs: authority.TrustPool()}) + if err == nil { + t.Fatal("erwartet fehlschlagenden handshake ohne client-zertifikat") + } + + clientIssued, err := authority.IssueCert("mail", time.Hour) + if err != nil { + t.Fatalf("client-zertifikat ausstellen: %v", err) + } + clientCert, err := clientIssued.TLSCertificate() + if err != nil { + t.Fatalf("client-zertifikat laden: %v", err) + } + status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, clientCert, authority)) + if err != nil { + t.Fatalf("erwartet erfolgreiche verbindung mit gueltigem client-zertifikat: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } +} + +// Akzeptanzkriterium 3 + Pruefung 2: Rotation der CA unterbricht laufenden +// Betrieb nicht — ein VOR der Rotation ausgestelltes Leaf-Zertifikat +// funktioniert danach weiter, UND ein NACH der Rotation neu ausgestelltes +// funktioniert ebenfalls, gegen denselben, weiterlaufenden Server (kein +// Neustart noetig, da TrustPool() denselben *x509.CertPool zurueckliefert, +// den Rotate() erweitert). +func TestAuthority_RotateWithoutDowntime(t *testing.T) { + authority, err := NewAuthority() + if err != nil { + t.Fatalf("newauthority: %v", err) + } + serverIssued, err := authority.IssueCert("core", time.Hour) + if err != nil { + t.Fatalf("server-zertifikat ausstellen: %v", err) + } + serverCert, err := serverIssued.TLSCertificate() + if err != nil { + t.Fatalf("server-zertifikat laden: %v", err) + } + + server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority)) + defer server.Close() // EIN Server-Prozess ueber die gesamte Rotation hinweg — kein Neustart. + + preRotationIssued, err := authority.IssueCert("dms", time.Hour) + if err != nil { + t.Fatalf("client-zertifikat (vor rotation) ausstellen: %v", err) + } + preRotationCert, _ := preRotationIssued.TLSCertificate() + + if err := authority.Rotate(); err != nil { + t.Fatalf("rotate: %v", err) + } + + // VOR der Rotation ausgestelltes Zertifikat funktioniert WEITERHIN, ohne + // dass der Server neu gestartet wurde. + status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, preRotationCert, authority)) + if err != nil { + t.Fatalf("vor-rotation-zertifikat haette weiterhin funktionieren sollen: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (vor-rotation-zertifikat)", status) + } + + // NACH der Rotation neu ausgestelltes Zertifikat funktioniert ebenfalls, + // gegen DENSELBEN laufenden Server. + postRotationIssued, err := authority.IssueCert("mail", time.Hour) + if err != nil { + t.Fatalf("client-zertifikat (nach rotation) ausstellen: %v", err) + } + postRotationCert, _ := postRotationIssued.TLSCertificate() + + status, err = fetch(t, server.URL, ClientTLSConfig(ModeRequired, postRotationCert, authority)) + if err != nil { + t.Fatalf("nach-rotation-zertifikat haette funktionieren sollen: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200 (nach-rotation-zertifikat)", status) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Stufe-1-Betrieb (ModeOff) funktioniert +// weiterhin OHNE mTLS-Zwang — ein Client ganz ohne Zertifikat kommt durch. +func TestServerTLSConfig_ModeOffWorksWithoutClientCert(t *testing.T) { + authority, err := NewAuthority() + if err != nil { + t.Fatalf("newauthority: %v", err) + } + serverIssued, err := authority.IssueCert("core", time.Hour) + if err != nil { + t.Fatalf("server-zertifikat ausstellen: %v", err) + } + serverCert, err := serverIssued.TLSCertificate() + if err != nil { + t.Fatalf("server-zertifikat laden: %v", err) + } + + server := newTLSServer(t, ServerTLSConfig(ModeOff, serverCert, authority)) + defer server.Close() + + status, err := fetch(t, server.URL, ClientTLSConfig(ModeOff, tls.Certificate{}, authority)) + if err != nil { + t.Fatalf("stufe-1-betrieb (ModeOff) haette ohne client-zertifikat funktionieren sollen: %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want 200", status) + } +}