erste module drin.

zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
br committed 2026-10-01 22:22:13 +02:00
1 parent 9d5789c5d7
commit c8df9d7651
41 files changed
+6455 -36

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
// Package crypto stellt die Transportverschlüsselung zwischen Agent und
// Server sicher (Spec Abschnitt 3.1):
//
// - Server: selbstsigniertes ECDSA-P-256-Zertifikat, einmalig erzeugt und
// dauerhaft im Datenverzeichnis abgelegt; nur TLS 1.3.
// - Agent: Zertifikats-Pinning über den SHA-256-Fingerprint
// (known_servers). Einen blinden Erstkontakt gibt es nicht: Beim ersten
// Verbinden muss der Fingerprint aus dem Enrollment-Token passen.
// - Enrollment-Token: sltn_enroll_<secret>.<fingerprint>. Der Server
// speichert nur das Secret (gehasht), der Agent nutzt den Fingerprint
// für den Erstkontakt.
package crypto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"path/filepath"
"time"
)
const (
serverKeyFile = "server.key"
serverCertFile = "server.crt"
certValidity = 20 * 365 * 24 * time.Hour
)
var (
// ErrIncompleteCert: Nur Schlüssel oder nur Zertifikat vorhanden. Es
// wird bewusst nichts neu erzeugt, weil das alle Pins der Agents brechen
// würde.
ErrIncompleteCert = errors.New("crypto: server.key und server.crt müssen beide vorhanden sein oder beide fehlen")
// ErrInsecureKeyFile: server.key ist für Gruppe oder andere lesbar.
ErrInsecureKeyFile = errors.New("crypto: server.key hat zu offene Rechte (erwartet 0600)")
// ErrInvalidFingerprint: Kein gültiger Fingerprint.
ErrInvalidFingerprint = errors.New("crypto: ungültiger Fingerprint")
)
// Fingerprint ist der SHA-256 des DER-kodierten Zertifikats, Base64url ohne
// Padding (43 Zeichen) – kompakt genug für den Enrollment-Token.
type Fingerprint string
// FingerprintOf berechnet den Fingerprint eines Zertifikats.
func FingerprintOf(cert *x509.Certificate) Fingerprint {
sum := sha256.Sum256(cert.Raw)
return Fingerprint(base64.RawURLEncoding.EncodeToString(sum[:]))
}
// ParseFingerprint prüft und übernimmt einen Fingerprint.
func ParseFingerprint(s string) (Fingerprint, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil || len(b) != sha256.Size {
return "", fmt.Errorf("%w: %q", ErrInvalidFingerprint, s)
}
return Fingerprint(s), nil
}
// ServerCert ist das TLS-Zertifikat des Servers samt Fingerprint.
type ServerCert struct {
Certificate tls.Certificate
Fingerprint Fingerprint
}
// Leaf liefert das geparste Zertifikat.
func (s ServerCert) Leaf() *x509.Certificate { return s.Certificate.Leaf }
// LoadOrCreateServerCert lädt server.key/server.crt aus dir oder erzeugt sie
// beim allerersten Start. Das Zertifikat ist 20 Jahre gültig: Agents pinnen
// den Fingerprint, ein Wechsel würde alle aussperren.
func LoadOrCreateServerCert(dir string) (ServerCert, error) {
keyPath, certPath := filepath.Join(dir, serverKeyFile), filepath.Join(dir, serverCertFile)
keyExists, err := exists(keyPath)
if err != nil {
return ServerCert{}, err
}
certExists, err := exists(certPath)
if err != nil {
return ServerCert{}, err
}
switch {
case keyExists && certExists:
case !keyExists && !certExists:
if err := createServerCert(keyPath, certPath); err != nil {
return ServerCert{}, err
}
default:
return ServerCert{}, fmt.Errorf("%w (in %s)", ErrIncompleteCert, dir)
}
info, err := os.Stat(keyPath)
if err != nil {
return ServerCert{}, err
}
if info.Mode().Perm()&0o077 != 0 {
return ServerCert{}, fmt.Errorf("%w: %s hat %o", ErrInsecureKeyFile, keyPath, info.Mode().Perm())
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return ServerCert{}, fmt.Errorf("crypto: Server-Zertifikat laden: %w", err)
}
if cert.Leaf == nil {
if cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]); err != nil {
return ServerCert{}, err
}
}
return ServerCert{Certificate: cert, Fingerprint: FingerprintOf(cert.Leaf)}, nil
}
func exists(path string) (bool, error) {
_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return err == nil, err
}
func createServerCert(keyPath, certPath string) error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "sultan-server"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(certValidity),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
return err
}
if err := writeNew(keyPath, 0o600, &pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}); err != nil {
return err
}
if err := writeNew(certPath, 0o644, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
os.Remove(keyPath)
return err
}
return nil
}
// writeNew legt path neu an (nie überschreiben) und setzt die Rechte
// unabhängig von der umask.
func writeNew(path string, perm os.FileMode, block *pem.Block) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm)
if err != nil {
return err
}
err = pem.Encode(f, block)
if cerr := f.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Chmod(path, perm)
}
if err != nil {
os.Remove(path)
}
return err
}
// ServerTLSConfig liefert die TLS-Konfiguration für den Agent-Port (:8443):
// nur TLS 1.3.
func ServerTLSConfig(sc ServerCert) *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{sc.Certificate},
MinVersion: tls.VersionTLS13,
}
}
+139
View File
@@ -0,0 +1,139 @@
package crypto
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/tls"
"crypto/x509"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadOrCreateServerCert_Creates(t *testing.T) {
dir := t.TempDir()
sc, err := LoadOrCreateServerCert(dir)
if err != nil {
t.Fatalf("LoadOrCreateServerCert: %v", err)
}
for name, wantPerm := range map[string]os.FileMode{serverKeyFile: 0o600, serverCertFile: 0o644} {
info, err := os.Stat(filepath.Join(dir, name))
if err != nil {
t.Fatalf("%s fehlt: %v", name, err)
}
if info.Mode().Perm() != wantPerm {
t.Errorf("%s hat %o, erwartet %o", name, info.Mode().Perm(), wantPerm)
}
}
leaf := sc.Leaf()
key, ok := leaf.PublicKey.(*ecdsa.PublicKey)
if !ok || key.Curve != elliptic.P256() {
t.Errorf("Schlüssel ist kein ECDSA P-256: %T", leaf.PublicKey)
}
// Selbstsigniert, aber bewusst keine CA (könnte sonst weitere
// Zertifikate ausstellen) – daher nicht CheckSignatureFrom.
if err := leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature); err != nil ||
leaf.Issuer.String() != leaf.Subject.String() {
t.Errorf("nicht selbstsigniert: %v", err)
}
if leaf.IsCA {
t.Error("Server-Zertifikat darf keine CA sein")
}
if len(leaf.ExtKeyUsage) != 1 || leaf.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth {
t.Errorf("ExtKeyUsage = %v, erwartet ServerAuth", leaf.ExtKeyUsage)
}
if leaf.NotAfter.Before(time.Now().AddDate(9, 0, 0)) {
t.Errorf("gültig nur bis %v – ein Ablauf würde alle Pins brechen", leaf.NotAfter)
}
if sc.Fingerprint != FingerprintOf(leaf) {
t.Error("Fingerprint passt nicht zum Zertifikat")
}
}
func TestLoadOrCreateServerCert_StableAcrossRestarts(t *testing.T) {
dir := t.TempDir()
first, err := LoadOrCreateServerCert(dir)
if err != nil {
t.Fatal(err)
}
second, err := LoadOrCreateServerCert(dir)
if err != nil {
t.Fatalf("erneutes Laden: %v", err)
}
if first.Fingerprint != second.Fingerprint {
t.Error("Fingerprint hat sich beim Neustart geändert – alle Agents würden die Verbindung verweigern")
}
}
func TestLoadOrCreateServerCert_Errors(t *testing.T) {
t.Run("nur Zertifikat vorhanden", func(t *testing.T) {
dir := t.TempDir()
LoadOrCreateServerCert(dir)
os.Remove(filepath.Join(dir, serverKeyFile))
if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrIncompleteCert) {
t.Errorf("erwartet ErrIncompleteCert, bekommen %v", err)
}
if _, err := os.Stat(filepath.Join(dir, serverKeyFile)); err == nil {
t.Error("fehlender Schlüssel wurde stillschweigend neu erzeugt")
}
})
t.Run("nur Schlüssel vorhanden", func(t *testing.T) {
dir := t.TempDir()
LoadOrCreateServerCert(dir)
os.Remove(filepath.Join(dir, serverCertFile))
if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrIncompleteCert) {
t.Errorf("erwartet ErrIncompleteCert, bekommen %v", err)
}
})
t.Run("Schlüssel zu offen", func(t *testing.T) {
dir := t.TempDir()
LoadOrCreateServerCert(dir)
os.Chmod(filepath.Join(dir, serverKeyFile), 0o644)
if _, err := LoadOrCreateServerCert(dir); !errors.Is(err, ErrInsecureKeyFile) {
t.Errorf("erwartet ErrInsecureKeyFile, bekommen %v", err)
}
})
t.Run("Schlüssel passt nicht zum Zertifikat", func(t *testing.T) {
a, b := t.TempDir(), t.TempDir()
LoadOrCreateServerCert(a)
LoadOrCreateServerCert(b)
data, _ := os.ReadFile(filepath.Join(b, serverCertFile))
os.WriteFile(filepath.Join(a, serverCertFile), data, 0o644)
if _, err := LoadOrCreateServerCert(a); err == nil {
t.Error("erwartet Fehler bei fremdem Zertifikat")
}
})
t.Run("Verzeichnis fehlt", func(t *testing.T) {
if _, err := LoadOrCreateServerCert(filepath.Join(t.TempDir(), "fehlt")); err == nil {
t.Error("erwartet Fehler")
}
})
}
func TestServerTLSConfig_OnlyTLS13(t *testing.T) {
sc, err := LoadOrCreateServerCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
cfg := ServerTLSConfig(sc)
if cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
}
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
srv.TLS = cfg
srv.StartTLS()
defer srv.Close()
old := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, MaxVersion: tls.VersionTLS12,
}}}
if _, err := old.Get(srv.URL); err == nil {
t.Error("TLS-1.2-Client wurde akzeptiert")
}
}
+142
View File
@@ -0,0 +1,142 @@
package crypto
import (
"bufio"
"bytes"
"crypto/tls"
"errors"
"fmt"
"os"
"strings"
"sync"
)
var (
// ErrUnknownServer: Kein Pin und kein erwarteter Fingerprint – ein
// ungeprüfter Erstkontakt wird verweigert.
ErrUnknownServer = errors.New("crypto: unbekannter Server – Fingerprint aus dem Enrollment-Token erforderlich")
// ErrFingerprintMismatch: Das Server-Zertifikat passt nicht zum Pin bzw.
// zum erwarteten Fingerprint (möglicher Man-in-the-Middle).
ErrFingerprintMismatch = errors.New("crypto: Server-Fingerprint stimmt nicht – möglicher Man-in-the-Middle")
// ErrCorruptKnownServers: Die known_servers-Datei ist nicht lesbar.
ErrCorruptKnownServers = errors.New("crypto: known_servers beschädigt")
)
// KnownServers ist die Pin-Datei des Agenten (analog zu SSH known_hosts).
// Format: eine Zeile "<server> <fingerprint>", # für Kommentare. Einträge
// werden nur angehängt, nie stillschweigend geändert.
type KnownServers struct {
Path string
mu sync.Mutex
}
// Lookup liefert den Pin für server. Eine fehlende Datei bedeutet: kein Pin.
func (k *KnownServers) Lookup(server string) (Fingerprint, bool, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.lookup(server)
}
func (k *KnownServers) lookup(server string) (Fingerprint, bool, error) {
data, err := os.ReadFile(k.Path)
if errors.Is(err, os.ErrNotExist) {
return "", false, nil
}
if err != nil {
return "", false, err
}
sc := bufio.NewScanner(bytes.NewReader(data))
for line := 1; sc.Scan(); line++ {
text := strings.TrimSpace(sc.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
fields := strings.Fields(text)
if len(fields) != 2 {
return "", false, fmt.Errorf("%w: %s Zeile %d", ErrCorruptKnownServers, k.Path, line)
}
fp, err := ParseFingerprint(fields[1])
if err != nil {
return "", false, fmt.Errorf("%w: %s Zeile %d: %w", ErrCorruptKnownServers, k.Path, line, err)
}
if fields[0] == server {
return fp, true, nil
}
}
return "", false, sc.Err()
}
// Add pinnt fp für server. Ist bereits ein anderer Fingerprint gepinnt,
// liefert Add ErrFingerprintMismatch und ändert nichts.
func (k *KnownServers) Add(server string, fp Fingerprint) error {
if len(strings.Fields(server)) != 1 || strings.TrimSpace(server) != server {
return fmt.Errorf("crypto: ungültiger Servername %q", server)
}
if _, err := ParseFingerprint(string(fp)); err != nil {
return err
}
k.mu.Lock()
defer k.mu.Unlock()
pinned, ok, err := k.lookup(server)
if err != nil {
return err
}
if ok {
if pinned != fp {
return fmt.Errorf("%w: %s ist auf %s gepinnt, nicht %s", ErrFingerprintMismatch, server, pinned, fp)
}
return nil
}
f, err := os.OpenFile(k.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s %s\n", server, fp)
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// ClientTLSConfig liefert die TLS-Konfiguration des Agenten für server
// (host:port aus der --server-URL). Statt einer CA prüft sie den
// Fingerprint:
//
// - Pin vorhanden: Das Zertifikat muss dazu passen (expected wird ignoriert).
// - Kein Pin, expected gesetzt (Fingerprint aus dem Enrollment-Token):
// Das Zertifikat muss dazu passen und wird dann gepinnt.
// - Kein Pin, kein expected: Verbindung wird verweigert (ErrUnknownServer).
//
// Die Prüfung läuft im TLS-Handshake, also bevor irgendein Request-Byte
// (Token, Signatur, Body) gesendet wird. Nur TLS 1.3.
func ClientTLSConfig(server string, ks *KnownServers, expected Fingerprint) *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS13,
// Die CA-Prüfung entfällt bewusst; VerifyConnection ersetzt sie
// durch die strengere Fingerprint-Prüfung.
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return fmt.Errorf("%w: kein Zertifikat", ErrFingerprintMismatch)
}
actual := FingerprintOf(cs.PeerCertificates[0])
pinned, ok, err := ks.Lookup(server)
if err != nil {
return err
}
if ok {
if actual != pinned {
return fmt.Errorf("%w: %s präsentiert %s, gepinnt ist %s", ErrFingerprintMismatch, server, actual, pinned)
}
return nil
}
if expected == "" {
return fmt.Errorf("%w (%s präsentiert %s)", ErrUnknownServer, server, actual)
}
if actual != expected {
return fmt.Errorf("%w: %s präsentiert %s, Token erwartet %s", ErrFingerprintMismatch, server, actual, expected)
}
return ks.Add(server, actual)
},
}
}
+193
View File
@@ -0,0 +1,193 @@
package crypto
import (
"crypto/tls"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
func newKnownServers(t *testing.T) *KnownServers {
t.Helper()
return &KnownServers{Path: filepath.Join(t.TempDir(), "known_servers")}
}
func TestKnownServers(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
other, _ := LoadOrCreateServerCert(t.TempDir())
if _, ok, err := ks.Lookup("ingress:8443"); ok || err != nil {
t.Fatalf("leere Datei: ok=%v err=%v", ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Fatalf("Add: %v", err)
}
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
if err := ks.Add("ingress:8443", sc.Fingerprint); err != nil {
t.Errorf("gleichen Eintrag erneut hinzufügen: %v", err)
}
if err := ks.Add("ingress:8443", other.Fingerprint); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("Pin überschreiben: erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if err := ks.Add("anderer:8443", other.Fingerprint); err != nil {
t.Errorf("zweiter Server: %v", err)
}
data, _ := os.ReadFile(ks.Path)
if n := strings.Count(string(data), "ingress:8443"); n != 1 {
t.Errorf("ingress:8443 steht %d-mal in der Datei", n)
}
info, _ := os.Stat(ks.Path)
if info.Mode().Perm() != 0o600 {
t.Errorf("Dateirechte %o, erwartet 600", info.Mode().Perm())
}
}
func TestKnownServers_FileFormat(t *testing.T) {
ks := newKnownServers(t)
sc, _ := LoadOrCreateServerCert(t.TempDir())
content := "# Kommentar\n\n ingress:8443 " + string(sc.Fingerprint) + " \n"
os.WriteFile(ks.Path, []byte(content), 0o600)
if fp, ok, err := ks.Lookup("ingress:8443"); !ok || err != nil || fp != sc.Fingerprint {
t.Errorf("Lookup = %q, %v, %v", fp, ok, err)
}
for name, bad := range map[string]string{
"ein Feld": "ingress:8443\n",
"drei Felder": "ingress:8443 " + string(sc.Fingerprint) + " x\n",
"kaputter Fingerprint": "ingress:8443 abc\n",
} {
t.Run(name, func(t *testing.T) {
os.WriteFile(ks.Path, []byte(bad), 0o600)
if _, _, err := ks.Lookup("ingress:8443"); !errors.Is(err, ErrCorruptKnownServers) {
t.Errorf("erwartet ErrCorruptKnownServers, bekommen %v", err)
}
})
}
if err := ks.Add("mit leerzeichen", sc.Fingerprint); err == nil {
t.Error("Servername mit Leerzeichen akzeptiert")
}
}
// tlsServer startet einen Test-Server mit dem Zertifikat aus einem eigenen
// Datenverzeichnis und zählt, ob ein Request ihn erreicht.
func tlsServer(t *testing.T) (*httptest.Server, ServerCert, *atomic.Int32) {
t.Helper()
sc, err := LoadOrCreateServerCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
var hits atomic.Int32
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
}))
srv.TLS = ServerTLSConfig(sc)
srv.Config.ErrorLog = log.New(io.Discard, "", 0) // abgewiesene Handshakes sind hier gewollt
srv.StartTLS()
t.Cleanup(srv.Close)
return srv, sc, &hits
}
func get(t *testing.T, srv *httptest.Server, cfg *tls.Config) error {
t.Helper()
c := &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
resp, err := c.Get(srv.URL)
if err == nil {
resp.Body.Close()
}
return err
}
func TestClientTLS_FirstContactWithExpectedFingerprint(t *testing.T) {
srv, sc, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, sc.Fingerprint)); err != nil {
t.Fatalf("erster Kontakt mit korrektem Fingerprint: %v", err)
}
if hits.Load() != 1 {
t.Error("Request kam nicht an")
}
if fp, ok, _ := ks.Lookup(addr); !ok || fp != sc.Fingerprint {
t.Errorf("Pin nicht gespeichert: %q %v", fp, ok)
}
// Folgekontakt ohne Token-Fingerprint: der Pin genügt.
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); err != nil {
t.Errorf("Folgekontakt mit Pin: %v", err)
}
}
// Kein blindes TOFU: Ohne Pin und ohne erwarteten Fingerprint wird nicht verbunden.
func TestClientTLS_NoBlindFirstContact(t *testing.T) {
srv, _, hits := tlsServer(t)
ks := newKnownServers(t)
addr := srv.Listener.Addr().String()
if err := get(t, srv, ClientTLSConfig(addr, ks, "")); !errors.Is(err, ErrUnknownServer) {
t.Errorf("erwartet ErrUnknownServer, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request hat den Server erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("unbekannter Server wurde gepinnt")
}
}
// Angreifer beim Erstkontakt: Er präsentiert sein eigenes Zertifikat, der
// Token enthält aber den Fingerprint des echten Servers.
func TestClientTLS_MITMOnFirstContact(t *testing.T) {
mitm, _, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := get(t, mitm, ClientTLSConfig(addr, ks, real.Fingerprint)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("erwartet ErrFingerprintMismatch, bekommen %v", err)
}
if hits.Load() != 0 {
t.Error("Request (inkl. Token) hat den Angreifer erreicht")
}
if _, ok, _ := ks.Lookup(addr); ok {
t.Error("Angreifer wurde gepinnt")
}
}
// Angreifer nach dem Erstkontakt: Der Pin gewinnt, auch wenn ein (neuer)
// Token-Fingerprint auf den Angreifer passen würde.
func TestClientTLS_PinWinsLater(t *testing.T) {
mitm, mitmCert, hits := tlsServer(t)
real, _ := LoadOrCreateServerCert(t.TempDir())
ks := newKnownServers(t)
addr := mitm.Listener.Addr().String()
if err := ks.Add(addr, real.Fingerprint); err != nil {
t.Fatal(err)
}
for _, expected := range []Fingerprint{"", mitmCert.Fingerprint} {
if err := get(t, mitm, ClientTLSConfig(addr, ks, expected)); !errors.Is(err, ErrFingerprintMismatch) {
t.Errorf("expected=%q: erwartet ErrFingerprintMismatch, bekommen %v", expected, err)
}
}
if hits.Load() != 0 {
t.Error("Request hat den Angreifer erreicht")
}
}
func TestClientTLSConfig_OnlyTLS13(t *testing.T) {
cfg := ClientTLSConfig("x:1", newKnownServers(t), "")
if cfg.MinVersion != tls.VersionTLS13 {
t.Errorf("MinVersion = %x, erwartet TLS 1.3", cfg.MinVersion)
}
}
+65
View File
@@ -0,0 +1,65 @@
package crypto
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
)
// TokenPrefix kennzeichnet Enrollment-Tokens.
const TokenPrefix = "sltn_enroll_"
// ErrInvalidToken: Der Token-String hat nicht das Format
// sltn_enroll_<secret>.<fingerprint>.
var ErrInvalidToken = errors.New("crypto: ungültiger Enrollment-Token")
// EnrollmentToken ist die Einladung für einen neuen Agenten.
//
// Secret (inkl. Präfix) ist der geheime Teil: Der Server speichert davon nur
// den Hash (store.AddEnrollmentToken), der Agent schickt es beim Enrollment.
// Fingerprint ist nicht geheim; er sagt dem Agenten, welches
// Server-Zertifikat er beim Erstkontakt erwarten muss.
type EnrollmentToken struct {
Secret string
Fingerprint Fingerprint
}
// String liefert die Form, die der Admin an den Agenten weitergibt.
func (t EnrollmentToken) String() string { return t.Secret + "." + string(t.Fingerprint) }
// NewEnrollmentToken erzeugt einen Token mit 256 Bit Zufall für den Server
// mit Fingerprint fp.
func NewEnrollmentToken(fp Fingerprint) (EnrollmentToken, error) {
if _, err := ParseFingerprint(string(fp)); err != nil {
return EnrollmentToken{}, err
}
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return EnrollmentToken{}, err
}
return EnrollmentToken{Secret: TokenPrefix + base64.RawURLEncoding.EncodeToString(b[:]), Fingerprint: fp}, nil
}
// ParseEnrollmentToken zerlegt einen Token-String. Leerraum am Rand (beim
// Kopieren) wird ignoriert.
func ParseEnrollmentToken(s string) (EnrollmentToken, error) {
parts := strings.Split(strings.TrimSpace(s), ".")
if len(parts) != 2 {
return EnrollmentToken{}, fmt.Errorf("%w: erwartet %s<secret>.<fingerprint>", ErrInvalidToken, TokenPrefix)
}
secret, rawFP := parts[0], parts[1]
body, ok := strings.CutPrefix(secret, TokenPrefix)
if !ok {
return EnrollmentToken{}, fmt.Errorf("%w: Präfix %s fehlt", ErrInvalidToken, TokenPrefix)
}
if b, err := base64.RawURLEncoding.DecodeString(body); err != nil || len(b) != 32 {
return EnrollmentToken{}, fmt.Errorf("%w: Secret beschädigt", ErrInvalidToken)
}
fp, err := ParseFingerprint(rawFP)
if err != nil {
return EnrollmentToken{}, fmt.Errorf("%w: %w", ErrInvalidToken, err)
}
return EnrollmentToken{Secret: secret, Fingerprint: fp}, nil
}
+92
View File
@@ -0,0 +1,92 @@
package crypto
import (
"errors"
"strings"
"testing"
)
func TestFingerprint_ParseRoundTrip(t *testing.T) {
sc, err := LoadOrCreateServerCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
got, err := ParseFingerprint(string(sc.Fingerprint))
if err != nil || got != sc.Fingerprint {
t.Errorf("ParseFingerprint = %q, %v", got, err)
}
for _, bad := range []string{"", "abc", strings.Repeat("A", 42), strings.Repeat("A", 44), strings.Repeat("+", 43), "sha256:" + string(sc.Fingerprint)} {
if _, err := ParseFingerprint(bad); !errors.Is(err, ErrInvalidFingerprint) {
t.Errorf("ParseFingerprint(%q): erwartet ErrInvalidFingerprint, bekommen %v", bad, err)
}
}
}
func TestEnrollmentToken_RoundTrip(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
tok, err := NewEnrollmentToken(sc.Fingerprint)
if err != nil {
t.Fatalf("NewEnrollmentToken: %v", err)
}
s := tok.String()
if !strings.HasPrefix(s, TokenPrefix) {
t.Errorf("Token %q ohne Präfix %q", s, TokenPrefix)
}
if !strings.HasPrefix(tok.Secret, TokenPrefix) || strings.Contains(tok.Secret, ".") {
t.Errorf("Secret %q: erwartet Präfix und keinen Fingerprint-Anteil", tok.Secret)
}
parsed, err := ParseEnrollmentToken(s)
if err != nil {
t.Fatalf("ParseEnrollmentToken: %v", err)
}
if parsed != tok {
t.Errorf("Round-Trip weicht ab: %+v vs %+v", parsed, tok)
}
if parsed2, _ := ParseEnrollmentToken(" " + s + "\n"); parsed2 != tok {
t.Error("Leerraum beim Kopieren sollte toleriert werden")
}
}
func TestEnrollmentToken_Unique(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
seen := map[string]bool{}
for range 100 {
tok, err := NewEnrollmentToken(sc.Fingerprint)
if err != nil {
t.Fatal(err)
}
if seen[tok.Secret] {
t.Fatal("doppeltes Secret")
}
seen[tok.Secret] = true
}
}
func TestEnrollmentToken_Errors(t *testing.T) {
sc, _ := LoadOrCreateServerCert(t.TempDir())
good, _ := NewEnrollmentToken(sc.Fingerprint)
secretPart := strings.TrimPrefix(good.Secret, TokenPrefix)
fp := string(sc.Fingerprint)
tests := map[string]string{
"leer": "",
"falsches Präfix": "sltn_token_" + secretPart + "." + fp,
"ohne Fingerprint": good.Secret,
"leerer Fingerprint": good.Secret + ".",
"kaputter Fingerprint": good.Secret + ".xyz",
"Secret zu kurz": TokenPrefix + "abc." + fp,
"Secret kein base64url": TokenPrefix + strings.Repeat("!", 43) + "." + fp,
"zu viele Teile": good.String() + ".extra",
}
for name, s := range tests {
t.Run(name, func(t *testing.T) {
if _, err := ParseEnrollmentToken(s); !errors.Is(err, ErrInvalidToken) {
t.Errorf("erwartet ErrInvalidToken, bekommen %v", err)
}
})
}
if _, err := NewEnrollmentToken("kaputt"); !errors.Is(err, ErrInvalidFingerprint) {
t.Errorf("NewEnrollmentToken mit kaputtem Fingerprint: %v", err)
}
}