From 0a477fd288d064d265395c88dbd0f261b723804e Mon Sep 17 00:00:00 2001 From: spiperac Date: Mon, 15 Jun 2026 06:42:13 +0200 Subject: Initial release --- internal/cert/ca.go | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 internal/cert/ca.go (limited to 'internal/cert/ca.go') diff --git a/internal/cert/ca.go b/internal/cert/ca.go new file mode 100644 index 0000000..76579a4 --- /dev/null +++ b/internal/cert/ca.go @@ -0,0 +1,298 @@ +// Package cert handles all certificate concerns for Shiv: +// - Root CA generation and persistent storage +// - Per-hostname TLS certificate generation and signing +// - In-memory cert cache (sync.Map, rebuilt each launch) +// - OS trust store installation (see install.go) +package cert + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + caKeySize = 4096 + hostKeySize = 2048 + caValidity = 10 * 365 * 24 * time.Hour + hostValidity = 365 * 24 * time.Hour +) + +// CA holds the loaded root certificate authority keypair and an +// in-memory cache of already-generated per-host certificates. +type CA struct { + cert *x509.Certificate + key *rsa.PrivateKey + cache sync.Map // map[string]*tls.Certificate + fresh bool +} + +// Fresh returns true if the CA was generated this run (not loaded from disk). +// Use this to decide whether to attempt OS trust store installation. +func (ca *CA) Fresh() bool { + return ca.fresh +} + +// Dir returns the directory where Shiv stores the CA files. +// Uses os.UserConfigDir()/shiv — created if it does not exist. +func Dir() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("cert: cannot find user config dir: %w", err) + } + dir := filepath.Join(configDir, "shiv") + if err := os.MkdirAll(dir, 0700); err != nil { + return "", fmt.Errorf("cert: cannot create config dir %s: %w", dir, err) + } + return dir, nil +} + +// Load loads the CA from disk, generating it if it does not exist. +// The CA keypair is stored in dir/ca.key and dir/ca.crt. +func Load() (*CA, error) { + dir, err := Dir() + if err != nil { + return nil, err + } + keyPath := filepath.Join(dir, "ca.key") + crtPath := filepath.Join(dir, "ca.crt") + + // If both files exist, load them. + if fileExists(keyPath) && fileExists(crtPath) { + return loadFromDisk(keyPath, crtPath) + } + + // Otherwise generate a fresh CA and persist it. + return generate(keyPath, crtPath) +} + +// LoadFromDir is like Load but uses an explicit directory. Useful in tests. +func LoadFromDir(dir string) (*CA, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("cert: cannot create dir %s: %w", dir, err) + } + keyPath := filepath.Join(dir, "ca.key") + crtPath := filepath.Join(dir, "ca.crt") + + if fileExists(keyPath) && fileExists(crtPath) { + return loadFromDisk(keyPath, crtPath) + } + return generate(keyPath, crtPath) +} + +// CertPEMPath returns the path to the CA certificate PEM file. +func CertPEMPath() (string, error) { + dir, err := Dir() + if err != nil { + return "", err + } + return filepath.Join(dir, "ca.crt"), nil +} + +// CertPEM returns the PEM-encoded CA certificate bytes. +func (ca *CA) CertPEM() ([]byte, error) { + return encodeCertPEM(ca.cert.Raw), nil +} + +// TLSCertForHost returns a *tls.Certificate for the given hostname, +// generating and caching one if it does not exist yet. +func (ca *CA) TLSCertForHost(host string) (*tls.Certificate, error) { + // Strip port if present. + hostname := host + if h, _, err := net.SplitHostPort(host); err == nil { + hostname = h + } + + if cachedCert, ok := ca.cache.Load(hostname); ok { + return cachedCert.(*tls.Certificate), nil + } + + tlsCert, err := ca.generateHostCert(hostname) + if err != nil { + return nil, err + } + + // Store-if-absent to avoid races generating the same cert twice. + actual, _ := ca.cache.LoadOrStore(hostname, tlsCert) + return actual.(*tls.Certificate), nil +} + +// X509Cert returns the underlying *x509.Certificate for the CA. +// Used by tests and trust store installation. +func (ca *CA) X509Cert() *x509.Certificate { + return ca.cert +} + +// --------------------------------------------------------------------- +// internal helpers +// --------------------------------------------------------------------- + +func generate(keyPath, crtPath string) (*CA, error) { + caKey, err := rsa.GenerateKey(rand.Reader, caKeySize) + if err != nil { + return nil, fmt.Errorf("cert: CA key generation failed: %w", err) + } + + serial, err := randomSerial() + if err != nil { + return nil, err + } + + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: "Shiv Local CA", + Organization: []string{"Shiv"}, + }, + NotBefore: time.Now().Add(-1 * time.Minute), // small back-date for clock skew + NotAfter: time.Now().Add(caValidity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 1, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &caKey.PublicKey, caKey) + if err != nil { + return nil, fmt.Errorf("cert: CA cert creation failed: %w", err) + } + + parsedCert, err := x509.ParseCertificate(derBytes) + if err != nil { + return nil, fmt.Errorf("cert: CA cert parse failed: %w", err) + } + + // Persist key. + if err := writePEM(keyPath, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(caKey), 0600); err != nil { + return nil, err + } + // Persist cert. + if err := writePEM(crtPath, "CERTIFICATE", derBytes, 0644); err != nil { + return nil, err + } + + return &CA{cert: parsedCert, key: caKey, fresh: true}, nil +} + +func loadFromDisk(keyPath, crtPath string) (*CA, error) { + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("cert: read CA key: %w", err) + } + crtPEM, err := os.ReadFile(crtPath) + if err != nil { + return nil, fmt.Errorf("cert: read CA cert: %w", err) + } + + keyBlock, _ := pem.Decode(keyPEM) + if keyBlock == nil { + return nil, errors.New("cert: CA key PEM decode failed") + } + caKey, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("cert: CA key parse: %w", err) + } + + certBlock, _ := pem.Decode(crtPEM) + if certBlock == nil { + return nil, errors.New("cert: CA cert PEM decode failed") + } + parsedCert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, fmt.Errorf("cert: CA cert parse: %w", err) + } + + if time.Now().After(parsedCert.NotAfter) { + return generate(keyPath, crtPath) + } + + return &CA{cert: parsedCert, key: caKey}, nil +} + +func (ca *CA) generateHostCert(hostname string) (*tls.Certificate, error) { + key, err := rsa.GenerateKey(rand.Reader, hostKeySize) + if err != nil { + return nil, fmt.Errorf("cert: host key generation failed for %s: %w", hostname, err) + } + + serial, err := randomSerial() + if err != nil { + return nil, err + } + + certTemplate := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: hostname, + }, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: time.Now().Add(hostValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{ + x509.ExtKeyUsageServerAuth, + }, + } + + // Support both IP addresses and DNS names. + if ip := net.ParseIP(hostname); ip != nil { + certTemplate.IPAddresses = []net.IP{ip} + } else { + certTemplate.DNSNames = []string{hostname} + } + + derBytes, err := x509.CreateCertificate(rand.Reader, certTemplate, ca.cert, &key.PublicKey, ca.key) + if err != nil { + return nil, fmt.Errorf("cert: host cert creation failed for %s: %w", hostname, err) + } + + tlsCert := &tls.Certificate{ + Certificate: [][]byte{derBytes, ca.cert.Raw}, + PrivateKey: key, + } + + // Parse and attach for field access (e.g. in tests). + leaf, err := x509.ParseCertificate(derBytes) + if err != nil { + return nil, fmt.Errorf("cert: host cert parse failed: %w", err) + } + tlsCert.Leaf = leaf + + return tlsCert, nil +} + +func randomSerial() (*big.Int, error) { + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("cert: serial generation failed: %w", err) + } + return serial, nil +} + +func writePEM(path, pemType string, derBytes []byte, mode os.FileMode) error { + pemFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("cert: open %s for write: %w", path, err) + } + defer pemFile.Close() + return pem.Encode(pemFile, &pem.Block{Type: pemType, Bytes: derBytes}) +} + +func encodeCertPEM(derBytes []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} -- cgit v1.2.3