From 044a8aa166799f8eb564cac0413621efa0b25ee4 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Sun, 1 Mar 2026 03:03:06 +0800 Subject: [PATCH] feat: add TLS certificate management with AnyIP and self-signed fallback --- internal/tls/generate.go | 72 ++++++++++++++++++++++++++++++++++++++++ internal/tls/tls.go | 62 ++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 internal/tls/generate.go create mode 100644 internal/tls/tls.go diff --git a/internal/tls/generate.go b/internal/tls/generate.go new file mode 100644 index 0000000..10e6589 --- /dev/null +++ b/internal/tls/generate.go @@ -0,0 +1,72 @@ +package tls + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "time" +) + +// generateSelfSigned creates a self-signed certificate for the given IP, +// saves it to disk, and returns the tls.Certificate. +func generateSelfSigned(lanIP, certFile, keyFile string) (tls.Certificate, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate key: %w", err) + } + + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate serial: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"VoicePaste"}, + CommonName: "VoicePaste Local", + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(365 * 24 * time.Hour), // 1 year + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP(lanIP)}, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create certificate: %w", err) + } + + // Save cert PEM + certOut, err := os.Create(certFile) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create cert file: %w", err) + } + defer certOut.Close() + pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + // Save key PEM + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, fmt.Errorf("marshal key: %w", err) + } + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create key file: %w", err) + } + defer keyOut.Close() + pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return tls.LoadX509KeyPair(certFile, keyFile) +} diff --git a/internal/tls/tls.go b/internal/tls/tls.go new file mode 100644 index 0000000..1b7c9ba --- /dev/null +++ b/internal/tls/tls.go @@ -0,0 +1,62 @@ +package tls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "log/slog" + "net" + "os" + "path/filepath" + "time" +) + +// certDir returns the directory for storing certificates. +func certDir() string { + home, _ := os.UserHomeDir() + dir := filepath.Join(home, ".voicepaste", "certs") + os.MkdirAll(dir, 0700) + return dir +} + +// GetTLSConfig returns a tls.Config for the given LAN IP. +// It tries to load cached self-signed certs, or generates new ones. +func GetTLSConfig(lanIP string) (*tls.Config, error) { + dir := certDir() + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + + // Try loading existing cert + if cert, err := tls.LoadX509KeyPair(certFile, keyFile); err == nil { + // Check if cert covers this IP and is not expired + if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil { + if time.Now().Before(leaf.NotAfter) && certCoversIP(leaf, lanIP) { + slog.Info("using cached TLS certificate", "expires", leaf.NotAfter.Format("2006-01-02")) + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil + } + } + } + + // Generate new self-signed cert + slog.Info("generating self-signed TLS certificate", "ip", lanIP) + cert, err := generateSelfSigned(lanIP, certFile, keyFile) + if err != nil { + return nil, fmt.Errorf("generate TLS cert: %w", err) + } + + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} + +// certCoversIP checks if the certificate covers the given IP. +func certCoversIP(cert *x509.Certificate, ip string) bool { + target := net.ParseIP(ip) + if target == nil { + return false + } + for _, certIP := range cert.IPAddresses { + if certIP.Equal(target) { + return true + } + } + return false +}