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,
}
}