diff options
Diffstat (limited to 'internal/cert')
| -rw-r--r-- | internal/cert/ca.go | 298 | ||||
| -rw-r--r-- | internal/cert/ca_test.go | 345 | ||||
| -rw-r--r-- | internal/cert/install.go | 149 |
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 + "\" ]") +} |
