summaryrefslogtreecommitdiff
path: root/internal/cert
diff options
context:
space:
mode:
authorspiperac <[email protected]>2026-06-15 06:42:13 +0200
committerspiperac <[email protected]>2026-06-15 06:42:13 +0200
commit0a477fd288d064d265395c88dbd0f261b723804e (patch)
tree9b129f8f41c907285b89ed8385679934bb59c14f /internal/cert
Initial release
Diffstat (limited to 'internal/cert')
-rw-r--r--internal/cert/ca.go298
-rw-r--r--internal/cert/ca_test.go345
-rw-r--r--internal/cert/install.go149
3 files changed, 792 insertions, 0 deletions
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
+}
diff --git a/internal/cert/ca_test.go b/internal/cert/ca_test.go
new file mode 100644
index 0000000..8922c32
--- /dev/null
+++ b/internal/cert/ca_test.go
@@ -0,0 +1,345 @@
+package cert_test
+
+import (
+ "crypto/tls"
+ "crypto/x509"
+ "os"
+ "path/filepath"
+ "slices"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/shiv/internal/cert"
+)
+
+// newTestCA creates a fresh CA in a temp dir and returns it along with
+// the dir path. The caller owns cleanup via t.Cleanup.
+func newTestCA(t *testing.T) (*cert.CA, string) {
+ t.Helper()
+ dir := t.TempDir()
+ ca, err := cert.LoadFromDir(dir)
+ if err != nil {
+ t.Fatalf("LoadFromDir: %v", err)
+ }
+ return ca, dir
+}
+
+// -----------------------------------------------------------------------
+// CA generation
+// -----------------------------------------------------------------------
+
+func TestCAGeneration(t *testing.T) {
+ ca, dir := newTestCA(t)
+
+ // Files must exist on disk.
+ for _, name := range []string{"ca.key", "ca.crt"} {
+ path := filepath.Join(dir, name)
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("expected file %s to exist: %v", name, err)
+ }
+ }
+
+ x := ca.X509Cert()
+ if x == nil {
+ t.Fatal("X509Cert() returned nil")
+ }
+ if !x.IsCA {
+ t.Error("CA cert must have IsCA = true")
+ }
+ if x.Subject.CommonName != "Shiv Local CA" {
+ t.Errorf("unexpected CN: %q", x.Subject.CommonName)
+ }
+ if time.Until(x.NotAfter) < 9*365*24*time.Hour {
+ t.Errorf("CA cert validity too short: expires %v", x.NotAfter)
+ }
+}
+
+// -----------------------------------------------------------------------
+// CA persistence — load from existing files
+// -----------------------------------------------------------------------
+
+func TestCAPersistence(t *testing.T) {
+ _, dir := newTestCA(t)
+
+ // Load again from the same directory — must succeed without regenerating.
+ ca2, err := cert.LoadFromDir(dir)
+ if err != nil {
+ t.Fatalf("second LoadFromDir: %v", err)
+ }
+
+ // Serial numbers must match — same cert was loaded, not regenerated.
+ _, dir2 := newTestCA(t)
+ ca3, err := cert.LoadFromDir(dir2)
+ if err != nil {
+ t.Fatalf("third LoadFromDir (fresh dir): %v", err)
+ }
+
+ x2 := ca2.X509Cert()
+ x3 := ca3.X509Cert()
+
+ // Same dir → same serial. Different dir → different serial (fresh CA).
+ // Load ca again from dir for serial comparison.
+ ca1reload, _ := cert.LoadFromDir(dir)
+ x1reload := ca1reload.X509Cert()
+
+ if x1reload.SerialNumber.Cmp(x2.SerialNumber) != 0 {
+ t.Errorf("reloaded CA has different serial: got %v want %v",
+ x2.SerialNumber, x1reload.SerialNumber)
+ }
+ if x2.SerialNumber.Cmp(x3.SerialNumber) == 0 {
+ t.Error("independent CAs should have different serials")
+ }
+}
+
+// -----------------------------------------------------------------------
+// Per-host cert — basic properties
+// -----------------------------------------------------------------------
+
+func TestHostCertProperties(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ hostnames := []string{"example.com", "sub.example.com", "localhost"}
+ for _, h := range hostnames {
+ t.Run(h, func(t *testing.T) {
+ tlsCert, err := ca.TLSCertForHost(h)
+ if err != nil {
+ t.Fatalf("TLSCertForHost(%q): %v", h, err)
+ }
+
+ leaf := tlsCert.Leaf
+ if leaf == nil {
+ t.Fatal("Leaf is nil")
+ }
+ if leaf.Subject.CommonName != h {
+ t.Errorf("CN = %q, want %q", leaf.Subject.CommonName, h)
+ }
+
+ found := slices.Contains(leaf.DNSNames, h)
+ if !found {
+ t.Errorf("hostname %q not in DNSNames %v", h, leaf.DNSNames)
+ }
+
+ if time.Until(leaf.NotAfter) < 364*24*time.Hour {
+ t.Errorf("host cert validity too short: expires %v", leaf.NotAfter)
+ }
+ })
+ }
+}
+
+// -----------------------------------------------------------------------
+// Per-host cert — IP address
+// -----------------------------------------------------------------------
+
+func TestHostCertIP(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ tlsCert, err := ca.TLSCertForHost("127.0.0.1")
+ if err != nil {
+ t.Fatalf("TLSCertForHost(127.0.0.1): %v", err)
+ }
+ leaf := tlsCert.Leaf
+ if len(leaf.IPAddresses) == 0 {
+ t.Fatal("expected IP in IPAddresses, got none")
+ }
+ if leaf.IPAddresses[0].String() != "127.0.0.1" {
+ t.Errorf("got IP %v, want 127.0.0.1", leaf.IPAddresses[0])
+ }
+ if len(leaf.DNSNames) != 0 {
+ t.Errorf("IP cert should have no DNSNames, got %v", leaf.DNSNames)
+ }
+}
+
+// -----------------------------------------------------------------------
+// Chain validation — the acceptance criterion from the design doc
+// -----------------------------------------------------------------------
+
+func TestCertChainValidation(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ tlsCert, err := ca.TLSCertForHost("example.com")
+ if err != nil {
+ t.Fatalf("TLSCertForHost: %v", err)
+ }
+
+ // Build a cert pool containing only our CA — simulates what a browser
+ // with the CA installed would do.
+ pool := x509.NewCertPool()
+ pool.AddCert(ca.X509Cert())
+
+ opts := x509.VerifyOptions{
+ DNSName: "example.com",
+ Roots: pool,
+ }
+
+ leaf := tlsCert.Leaf
+ if _, err := leaf.Verify(opts); err != nil {
+ t.Errorf("chain verification failed: %v", err)
+ }
+}
+
+// -----------------------------------------------------------------------
+// TLS handshake — end-to-end using net/tls in-process
+// -----------------------------------------------------------------------
+
+func TestTLSHandshake(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ serverTLSCert, err := ca.TLSCertForHost("testhost.local")
+ if err != nil {
+ t.Fatalf("TLSCertForHost: %v", err)
+ }
+ serverCfg := &tls.Config{
+ Certificates: []tls.Certificate{*serverTLSCert},
+ }
+
+ pool := x509.NewCertPool()
+ pool.AddCert(ca.X509Cert())
+ clientCfg := &tls.Config{
+ RootCAs: pool,
+ ServerName: "testhost.local",
+ }
+
+ // tls.Dial inside newTLSPipe already completes the handshake.
+ // If it returns without error, the chain validated successfully.
+ serverConn, clientConn := newTLSPipe(t, serverCfg, clientCfg)
+ defer serverConn.Close()
+ defer clientConn.Close()
+}
+
+// newTLSPipe creates an in-process TLS client/server pair over TCP loopback.
+func newTLSPipe(t *testing.T, serverCfg, clientCfg *tls.Config) (*tls.Conn, *tls.Conn) {
+ t.Helper()
+
+ ch := make(chan *tls.Conn, 1)
+ ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg)
+ if err != nil {
+ t.Fatalf("tls.Listen: %v", err)
+ }
+ t.Cleanup(func() { ln.Close() })
+
+ go func() {
+ conn, err := ln.Accept()
+ if err != nil {
+ ch <- nil
+ return
+ }
+ tlsConn := conn.(*tls.Conn)
+ tlsConn.Handshake() // complete server side
+ ch <- tlsConn
+ }()
+
+ client, err := tls.Dial("tcp", ln.Addr().String(), clientCfg)
+ if err != nil {
+ t.Fatalf("tls.Dial: %v", err)
+ }
+
+ server := <-ch
+ if server == nil {
+ t.Fatal("server accept failed")
+ }
+
+ return server, client
+}
+
+// -----------------------------------------------------------------------
+// Cache — same *tls.Certificate pointer returned for same host
+// -----------------------------------------------------------------------
+
+func TestHostCertCache(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ first, err := ca.TLSCertForHost("cache-test.com")
+ if err != nil {
+ t.Fatalf("first call: %v", err)
+ }
+ second, err := ca.TLSCertForHost("cache-test.com")
+ if err != nil {
+ t.Fatalf("second call: %v", err)
+ }
+
+ if first != second {
+ t.Error("expected same *tls.Certificate pointer from cache")
+ }
+}
+
+// -----------------------------------------------------------------------
+// Cache — concurrent access does not race or duplicate
+// -----------------------------------------------------------------------
+
+func TestHostCertCacheConcurrent(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ const goroutines = 20
+ results := make([]*tls.Certificate, goroutines)
+ var wg sync.WaitGroup
+
+ for i := range goroutines {
+ i := i
+ wg.Go(func() {
+ c, err := ca.TLSCertForHost("concurrent.example.com")
+ if err != nil {
+ t.Errorf("goroutine %d: %v", i, err)
+ return
+ }
+ results[i] = c
+ })
+ }
+ wg.Wait()
+
+ for i, c := range results {
+ if c == nil {
+ t.Errorf("goroutine %d returned nil", i)
+ continue
+ }
+ if c != results[0] {
+ t.Errorf("goroutine %d returned different pointer", i)
+ }
+ }
+}
+
+// -----------------------------------------------------------------------
+// Host + port — port should be stripped before cert lookup
+// -----------------------------------------------------------------------
+
+func TestHostCertStripsPort(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ withPort, err := ca.TLSCertForHost("strip-test.com:443")
+ if err != nil {
+ t.Fatalf("with port: %v", err)
+ }
+ withoutPort, err := ca.TLSCertForHost("strip-test.com")
+ if err != nil {
+ t.Fatalf("without port: %v", err)
+ }
+
+ if withPort != withoutPort {
+ t.Error("port stripping broken: different certs returned for same host")
+ }
+
+ if withPort.Leaf.Subject.CommonName != "strip-test.com" {
+ t.Errorf("CN should be bare hostname, got %q", withPort.Leaf.Subject.CommonName)
+ }
+}
+
+// -----------------------------------------------------------------------
+// CertPEM — returned bytes are valid PEM
+// -----------------------------------------------------------------------
+
+func TestCertPEM(t *testing.T) {
+ ca, _ := newTestCA(t)
+
+ pem, err := ca.CertPEM()
+ if err != nil {
+ t.Fatalf("CertPEM: %v", err)
+ }
+ if len(pem) == 0 {
+ t.Fatal("CertPEM returned empty bytes")
+ }
+
+ pool := x509.NewCertPool()
+ if !pool.AppendCertsFromPEM(pem) {
+ t.Error("AppendCertsFromPEM failed — invalid PEM")
+ }
+}
diff --git a/internal/cert/install.go b/internal/cert/install.go
new file mode 100644
index 0000000..8ea25b9
--- /dev/null
+++ b/internal/cert/install.go
@@ -0,0 +1,149 @@
+package cert
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+// InstallCA attempts to install the Shiv CA into the user-level trust store.
+// Never requires sudo. Returns a human-readable status string, or an error
+// with manual instructions if automatic installation is not possible.
+func (ca *CA) InstallCA() (string, error) {
+ certPath, err := CertPEMPath()
+ if err != nil {
+ return "", err
+ }
+
+ switch runtime.GOOS {
+ case "linux":
+ return installLinux(certPath)
+ case "darwin":
+ return installDarwin(certPath)
+ case "windows":
+ return installWindows(certPath)
+ default:
+ return "", manualInstructions(certPath, fmt.Sprintf("unsupported OS: %s", runtime.GOOS))
+ }
+}
+
+func installLinux(certPath string) (string, error) {
+ if _, err := exec.LookPath("certutil"); err != nil {
+ return "", manualInstructions(certPath, "certutil not found (install libnss3-tools on Debian/Ubuntu or nss on Arch)")
+ }
+
+ var installed []string
+ var errs []string
+
+ // Chrome/Chromium user NSS database.
+ home, err := os.UserHomeDir()
+ if err == nil {
+ nssdb := filepath.Join(home, ".pki", "nssdb")
+ if err := os.MkdirAll(nssdb, 0700); err == nil {
+ cmd := exec.Command("certutil", "-A", "-n", "Shiv CA", "-t", "CT,,", "-i", certPath, "-d", "sql:"+nssdb)
+ if cmdOut, err := cmd.CombinedOutput(); err != nil {
+ errs = append(errs, fmt.Sprintf("Chrome NSS: %v\n%s", err, cmdOut))
+ } else {
+ installed = append(installed, "Chrome/Chromium")
+ }
+ }
+ }
+
+ // Firefox profiles.
+ profiles, _ := firefoxProfiles()
+ for _, profile := range profiles {
+ cmd := exec.Command("certutil", "-A", "-n", "Shiv CA", "-t", "CT,,", "-i", certPath, "-d", profile)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ errs = append(errs, fmt.Sprintf("Firefox profile %s: %v\n%s", profile, err, out))
+ } else {
+ installed = append(installed, "Firefox ("+filepath.Base(profile)+")")
+ }
+ }
+
+ if len(installed) == 0 {
+ msg := "certutil ran but no stores were updated"
+ if len(errs) > 0 {
+ msg = strings.Join(errs, "\n")
+ }
+ return "", manualInstructions(certPath, msg)
+ }
+
+ result := "CA certificate installed for: " + strings.Join(installed, ", ") + "."
+ if len(errs) > 0 {
+ result += "\nSome stores failed:\n" + strings.Join(errs, "\n")
+ }
+ return result, nil
+}
+
+func installDarwin(certPath string) (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", manualInstructions(certPath, fmt.Sprintf("cannot find home directory: %v", err))
+ }
+
+ loginKeychain := filepath.Join(home, "Library", "Keychains", "login.keychain-db")
+ cmd := exec.Command("security", "add-trusted-cert",
+ "-r", "trustRoot",
+ "-k", loginKeychain,
+ certPath,
+ )
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return "", manualInstructions(certPath, fmt.Sprintf("security add-trusted-cert failed: %v\n%s", err, out))
+ }
+ return "CA certificate installed to macOS login keychain.", nil
+}
+
+func installWindows(certPath string) (string, error) {
+ // certutil is built into Windows, no extra tools needed.
+ // -user flag installs to current user store, no admin required.
+ cmd := exec.Command("certutil", "-addstore", "-user", "-f", "ROOT", certPath)
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return "", manualInstructions(certPath, fmt.Sprintf("certutil failed: %v\n%s", err, out))
+ }
+ return "CA certificate installed to Windows user root store.", nil
+}
+
+func firefoxProfiles() ([]string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return nil, err
+ }
+
+ var profiles []string
+
+ profileDirs := []string{
+ filepath.Join(home, ".mozilla", "firefox"),
+ filepath.Join(home, ".librewolf"),
+ filepath.Join(home, "snap", "firefox", "common", ".mozilla", "firefox"),
+ filepath.Join(home, ".var", "app", "org.mozilla.firefox", ".mozilla", "firefox"),
+ filepath.Join(home, ".var", "app", "io.gitlab.librewolf-community", ".librewolf"),
+ }
+
+ for _, dir := range profileDirs {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ continue
+ }
+ for _, dirEntry := range entries {
+ if dirEntry.IsDir() {
+ profiles = append(profiles, filepath.Join(dir, dirEntry.Name()))
+ }
+ }
+ }
+
+ return profiles, nil
+}
+
+func manualInstructions(certPath, reason string) error {
+ return errors.New(reason + "\n\n" +
+ "To install manually, import this file into your browser:\n" +
+ " " + certPath + "\n\n" +
+ "Browser instructions:\n" +
+ " Chrome/Chromium: Settings → Privacy → Security → Manage certificates → Authorities → Import\n" +
+ " Firefox: Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import\n" +
+ " NixOS: security.pki.certificateFiles = [ \"" + certPath + "\" ]")
+}