Files
Tobias GesellchenandClaude Sonnet 4.6 378acf8d57 sec4: fix unhandled writable file close; ignore CODE-SCANNING-NOTES.md
Closes CodeQL alerts 280 and 281 (go/unhandled-writable-file-close).

scripts/extract-ws/main.go: change bare 'defer f.Close()' to
'defer func() { _ = f.Close() }()' — function returns void, silent
discard is the correct pattern (matches existing '_, _ = w.Write()'
usage elsewhere).

pkg/service/certmanager/certmanager.go: sequence encode + close for
both the cert file and the key file, checking both errors. This also
fixes resource leaks on the pem.Encode error path (file was previously
left open when encode failed). Matches the established pattern in
handlers_export.go (tw.Close / gz.Close).

.gitignore: exclude CODE-SCANNING-NOTES.md (local working notes;
will be added to VCS once the scanning sweep is complete and the
notes are stable).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 15:52:35 +02:00

299 lines
7.3 KiB
Go

// Package certmanager provides tools for managing Root CAs and generating SSL certificates.
package certmanager
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
)
// CertificateManager handles CA and certificate generation.
type CertificateManager struct {
CertsDir string
CommonName string // CN for generated server certs; defaults to "localhost" if empty
}
// NewCertificateManager creates a new CertificateManager.
func NewCertificateManager(certsDir string) *CertificateManager {
return &CertificateManager{CertsDir: certsDir}
}
// GetCACertPath returns the path to the CA certificate.
func (cm *CertificateManager) GetCACertPath() string {
return filepath.Join(cm.CertsDir, "ca.crt")
}
// GetCAKeyPath returns the path to the CA private key.
func (cm *CertificateManager) GetCAKeyPath() string {
return filepath.Join(cm.CertsDir, "ca.key")
}
// EnsureCA ensures that a CA certificate and key exist.
func (cm *CertificateManager) EnsureCA() error {
certPath := cm.GetCACertPath()
keyPath := cm.GetCAKeyPath()
if _, err := os.Stat(certPath); err == nil {
if _, err := os.Stat(keyPath); err == nil {
return nil
}
}
return cm.GenerateCA()
}
// GetServerCertPEMPath returns the path to the server certificate PEM.
func (cm *CertificateManager) GetServerCertPEMPath() string {
return filepath.Join(cm.CertsDir, "server.crt")
}
// GetServerKeyPEMPath returns the path to the server private key PEM.
func (cm *CertificateManager) GetServerKeyPEMPath() string {
return filepath.Join(cm.CertsDir, "server.key")
}
// GetServerTLSConfig returns a TLS config with the server certificate.
// If the certificate doesn't exist, it generates one for the given domains.
func (cm *CertificateManager) GetServerTLSConfig(domains []string) (*tls.Config, error) {
certPath := cm.GetServerCertPEMPath()
keyPath := cm.GetServerKeyPEMPath()
generate := false
if _, err := os.Stat(certPath); os.IsNotExist(err) {
generate = true
} else {
// Check if the current certificate covers all requested domains
certBytes, err := os.ReadFile(certPath)
if err == nil {
block, _ := pem.Decode(certBytes)
if block != nil {
cert, err := x509.ParseCertificate(block.Bytes)
if err == nil {
domainMap := make(map[string]bool)
for _, d := range cert.DNSNames {
domainMap[d] = true
}
for _, ip := range cert.IPAddresses {
domainMap[ip.String()] = true
}
for _, d := range domains {
if !domainMap[d] {
generate = true
break
}
}
} else {
generate = true
}
} else {
generate = true
}
} else {
generate = true
}
}
if generate {
certPEM, keyPEM, err := cm.GenerateCertificate(domains)
if err != nil {
return nil, err
}
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
return nil, err
}
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
return nil, err
}
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
},
}, nil
}
// GenerateCA generates a new CA certificate and key.
func (cm *CertificateManager) GenerateCA() error {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
notBefore := time.Now()
notAfter := notBefore.Add(10 * 365 * 24 * time.Hour) // 10 years
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"AfterTouch"},
CommonName: "AfterTouch Local Root CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return err
}
certPath := cm.GetCACertPath()
if mkdirErr := os.MkdirAll(cm.CertsDir, 0755); mkdirErr != nil {
return mkdirErr
}
certOut, err := os.Create(certPath)
if err != nil {
return err
}
certEncodeErr := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
certCloseErr := certOut.Close()
if certEncodeErr != nil {
return certEncodeErr
}
if certCloseErr != nil {
return fmt.Errorf("close certificate file: %w", certCloseErr)
}
keyOut, err := os.OpenFile(cm.GetCAKeyPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
keyEncodeErr := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
keyCloseErr := keyOut.Close()
if keyEncodeErr != nil {
return keyEncodeErr
}
if keyCloseErr != nil {
return fmt.Errorf("close key file: %w", keyCloseErr)
}
return nil
}
// GenerateCertificate generates a certificate for the given domains signed by the CA.
func (cm *CertificateManager) GenerateCertificate(domains []string) ([]byte, []byte, error) {
if err := cm.EnsureCA(); err != nil {
return nil, nil, err
}
caCertPEM, err := os.ReadFile(cm.GetCACertPath())
if err != nil {
return nil, nil, err
}
caKeyPEM, err := os.ReadFile(cm.GetCAKeyPath())
if err != nil {
return nil, nil, err
}
caBlock, _ := pem.Decode(caCertPEM)
caCert, err := x509.ParseCertificate(caBlock.Bytes)
if err != nil {
return nil, nil, err
}
keyBlock, _ := pem.Decode(caKeyPEM)
caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes)
if err != nil {
return nil, nil, err
}
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}
var (
dnsNames []string
ipAddresses []net.IP
)
for _, domain := range domains {
if ip := net.ParseIP(domain); ip != nil {
ipAddresses = append(ipAddresses, ip)
} else {
dnsNames = append(dnsNames, domain)
}
}
cn := cm.CommonName
if cn == "" {
cn = "localhost"
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"AfterTouch"},
CommonName: cn,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &priv.PublicKey, caKey)
if err != nil {
return nil, nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM, nil
}