diff --git a/README.md b/README.md index 4430cc03..4b90794e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ mkcert is a simple tool for making locally-trusted development certificates. It requires no configuration. -``` +```sh $ mkcert -install Created a new local CA 💥 The local CA is now installed in the system trust store! ⚡️ @@ -21,8 +21,6 @@ Created a new certificate valid for the following names 📜 The certificate is at "./example.com+5.pem" and the key at "./example.com+5-key.pem" ✅ ``` -

Chrome and Firefox screenshot

- Using certificates from real certificate authorities (CAs) for development can be dangerous or impossible (for hosts like `example.test`, `localhost` or `127.0.0.1`), but self-signed certificates cause trust errors. Managing your own CA is the best solution, but usually involves arcane commands, specialized knowledge and manual steps. mkcert automatically creates and installs a local CA in the system root store, and generates locally-trusted certificates. mkcert does not automatically configure servers to use the certificates, though, that's up to you. @@ -35,14 +33,14 @@ mkcert automatically creates and installs a local CA in the system root store, a On macOS, use [Homebrew](https://brew.sh/) -``` +```sh brew install mkcert brew install nss # if you use Firefox ``` or [MacPorts](https://www.macports.org/). -``` +```sh sudo port selfupdate sudo port install mkcert sudo port install nss # if you use Firefox @@ -52,7 +50,7 @@ sudo port install nss # if you use Firefox On Linux, first install `certutil`. -``` +```sh sudo apt install libnss3-tools -or- sudo yum install nss-tools @@ -64,20 +62,20 @@ sudo zypper install mozilla-nss-tools Then you can install using [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) -``` +```sh brew install mkcert ``` or build from source (requires Go 1.13+) -``` +```sh git clone https://github.com/FiloSottile/mkcert && cd mkcert go build -ldflags "-X main.Version=$(git describe --tags)" ``` or use [the pre-built binaries](https://github.com/FiloSottile/mkcert/releases). -``` +```sh curl -JLO "https://dl.filippo.io/mkcert/latest?for=linux/amd64" chmod +x mkcert-v*-linux-amd64 sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert @@ -85,7 +83,7 @@ sudo cp mkcert-v*-linux-amd64 /usr/local/bin/mkcert For Arch Linux users, [`mkcert`](https://archlinux.org/packages/extra/x86_64/mkcert/) is available on the official Arch Linux repository. -``` +```sh sudo pacman -Syu mkcert ``` @@ -93,13 +91,13 @@ sudo pacman -Syu mkcert On Windows, use [Chocolatey](https://chocolatey.org) -``` +```sh choco install mkcert ``` or use Scoop -``` +```sh scoop bucket add extras scoop install mkcert ``` @@ -115,9 +113,9 @@ mkcert supports the following root stores: * macOS system store * Windows system store * Linux variants that provide either - * `update-ca-trust` (Fedora, RHEL, CentOS) or - * `update-ca-certificates` (Ubuntu, Debian, OpenSUSE, SLES) or - * `trust` (Arch) + * `update-ca-trust` (Fedora, RHEL, CentOS) or + * `update-ca-certificates` (Ubuntu, Debian, OpenSUSE, SLES) or + * `trust` (Arch) * Firefox (macOS and Linux only) * Chrome and Chromium * Java (when `JAVA_HOME` is set) @@ -128,38 +126,52 @@ To only install the local root CA into a subset of them, you can set the `TRUST_ ### Advanced options -``` - -cert-file FILE, -key-file FILE, -p12-file FILE - Customize the output paths. +```text + -cert-file FILE, -key-file FILE, -p12-file FILE + Customize the output paths. + + -client + Generate a certificate for client authentication. + + -ecdsa + Generate a certificate with an ECDSA key. - -client - Generate a certificate for client authentication. + -pkcs12 + Generate a ".p12" PKCS #12 file, also know as a ".pfx" file, + containing certificate and key for legacy applications. - -ecdsa - Generate a certificate with an ECDSA key. + -csr CSR + Generate a certificate based on the supplied CSR. Conflicts with + all other flags and arguments except -install and -cert-file. - -pkcs12 - Generate a ".p12" PKCS #12 file, also know as a ".pfx" file, - containing certificate and key for legacy applications. + -validity DURATION, -cert-validity DURATION + Set custom validity for the certificate (e.g. 2y3m, 365d, 30d, 24h). + Defaults to 2 years and 3 months. - -csr CSR - Generate a certificate based on the supplied CSR. Conflicts with - all other flags and arguments except -install and -cert-file. + -days DAYS + Set custom certificate validity in days (alias for -validity DAYSd). + + -ca-validity DURATION + Set custom validity for the CA certificate when creating a new CA + (e.g. 10y, 5y, 3650d). Defaults to 10 years. ``` > **Note:** You _must_ place these options before the domain names list. #### Example -``` +```sh mkcert -key-file key.pem -cert-file cert.pem example.com *.example.com +mkcert -validity 30d example.com +mkcert -days 90 example.com +mkcert -ca-validity 5y -install ``` ### S/MIME mkcert automatically generates an S/MIME certificate if one of the supplied names is an email address. -``` +```sh mkcert filippo@example.com ``` @@ -175,7 +187,7 @@ For Android, you will have to install the CA and then enable user roots in the d Node does not use the system root store, so it won't accept mkcert certificates automatically. Instead, you will have to set the [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#cli_node_extra_ca_certs_file) environment variable. -``` +```sh export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" ``` @@ -194,4 +206,4 @@ Installing in the trust store does not require the CA key, so you can export the * set `$CAROOT` to its directory * run `mkcert -install` -Remember that mkcert is meant for development purposes, not production, so it should not be used on end users' machines, and that you should *not* export or share `rootCA-key.pem`. +Remember that mkcert is meant for development purposes, not production, so it should not be used on end users' machines, and that you should _not_ export or share `rootCA-key.pem`. diff --git a/cert.go b/cert.go index 4ce36ccf..b76502cb 100644 --- a/cert.go +++ b/cert.go @@ -56,10 +56,11 @@ func (m *mkcert) makeCert(hosts []string) { fatalIfErr(err, "failed to generate certificate key") pub := priv.(crypto.Signer).Public() - // Certificates last for 2 years and 3 months, which is always less than + // Certificates last for 2 years and 3 months by default, which is always less than // 825 days, the limit that macOS/iOS apply to all certificates, // including custom roots. See https://support.apple.com/en-us/HT210176. - expiration := time.Now().AddDate(2, 3, 0) + notBefore := time.Now() + expiration := m.certExpiration(notBefore) tpl := &x509.Certificate{ SerialNumber: randomSerialNumber(), @@ -68,7 +69,7 @@ func (m *mkcert) makeCert(hosts []string) { OrganizationalUnit: []string{userAndHostname}, }, - NotBefore: time.Now(), NotAfter: expiration, + NotBefore: notBefore, NotAfter: expiration, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, } @@ -225,13 +226,14 @@ func (m *mkcert) makeCertFromCSR() { fatalIfErr(err, "failed to parse the CSR") fatalIfErr(csr.CheckSignature(), "invalid CSR signature") - expiration := time.Now().AddDate(2, 3, 0) + notBefore := time.Now() + expiration := m.certExpiration(notBefore) tpl := &x509.Certificate{ SerialNumber: randomSerialNumber(), Subject: csr.Subject, ExtraExtensions: csr.Extensions, // includes requested SANs, KUs and EKUs - NotBefore: time.Now(), NotAfter: expiration, + NotBefore: notBefore, NotAfter: expiration, // If the CSR does not request a SAN extension, fix it up for them as // the Common Name field does not work in modern browsers. Otherwise, @@ -282,6 +284,8 @@ func (m *mkcert) makeCertFromCSR() { func (m *mkcert) loadCA() { if !pathExists(filepath.Join(m.CAROOT, rootName)) { m.newCA() + } else if m.caValidity != "" { + log.Println("Note: the local CA already exists, -ca-validity was ignored (to recreate the CA, delete rootCA.pem and rootCA-key.pem from CAROOT)") } certPEMBlock, err := ioutil.ReadFile(filepath.Join(m.CAROOT, rootName)) @@ -324,6 +328,9 @@ func (m *mkcert) newCA() { skid := sha1.Sum(spki.SubjectPublicKey.Bytes) + notBefore := time.Now() + expiration := m.caExpiration(notBefore) + tpl := &x509.Certificate{ SerialNumber: randomSerialNumber(), Subject: pkix.Name{ @@ -337,8 +344,8 @@ func (m *mkcert) newCA() { }, SubjectKeyId: skid[:], - NotAfter: time.Now().AddDate(10, 0, 0), - NotBefore: time.Now(), + NotAfter: expiration, + NotBefore: notBefore, KeyUsage: x509.KeyUsageCertSign, @@ -366,3 +373,24 @@ func (m *mkcert) newCA() { func (m *mkcert) caUniqueName() string { return "mkcert development CA " + m.caCert.SerialNumber.String() } + +func (m *mkcert) certExpiration(notBefore time.Time) time.Time { + if m.certValidity != "" { + dur, err := parseValidity(m.certValidity) + fatalIfErr(err, "invalid certificate validity") + return dur.apply(notBefore) + } + // Certificates last for 2 years and 3 months, which is always less than + // 825 days, the limit that macOS/iOS apply to all certificates, + // including custom roots. See https://support.apple.com/en-us/HT210176. + return notBefore.AddDate(2, 3, 0) +} + +func (m *mkcert) caExpiration(notBefore time.Time) time.Time { + if m.caValidity != "" { + dur, err := parseValidity(m.caValidity) + fatalIfErr(err, "invalid CA validity") + return dur.apply(notBefore) + } + return notBefore.AddDate(10, 0, 0) +} diff --git a/main.go b/main.go index 6c5e835b..309a1e9b 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "regexp" "runtime" "runtime/debug" + "strconv" "strings" "sync" @@ -65,6 +66,17 @@ const advancedUsage = `Advanced options: Generate a certificate based on the supplied CSR. Conflicts with all other flags and arguments except -install and -cert-file. + -validity DURATION, -cert-validity DURATION + Set custom validity for the certificate (e.g. 2y3m, 365d, 30d, 24h). + Defaults to 2 years and 3 months. + + -days DAYS + Set custom certificate validity in days (alias for -validity DAYSd). + + -ca-validity DURATION + Set custom validity for the CA certificate when creating a new CA + (e.g. 10y, 5y, 3650d). Defaults to 10 years. + -CAROOT Print the CA certificate and key storage location. @@ -72,6 +84,12 @@ const advancedUsage = `Advanced options: Set the CA certificate and key storage location. (This allows maintaining multiple local CAs in parallel.) + $CA_VALIDITY (environment variable) + Set custom validity for the CA certificate when creating a new CA. + + $CERT_VALIDITY (environment variable) + Set custom validity for certificates. + $TRUST_STORES (environment variable) A comma-separated list of trust stores to install the local root CA into. Options are: "system", "java" and "nss" (includes @@ -99,10 +117,14 @@ func main() { helpFlag = flag.Bool("help", false, "") carootFlag = flag.Bool("CAROOT", false, "") csrFlag = flag.String("csr", "", "") - certFileFlag = flag.String("cert-file", "", "") - keyFileFlag = flag.String("key-file", "", "") - p12FileFlag = flag.String("p12-file", "", "") - versionFlag = flag.Bool("version", false, "") + certFileFlag = flag.String("cert-file", "", "") + keyFileFlag = flag.String("key-file", "", "") + p12FileFlag = flag.String("p12-file", "", "") + versionFlag = flag.Bool("version", false, "") + caValidityFlag = flag.String("ca-validity", "", "") + certValidityFlag = flag.String("cert-validity", "", "") + validityFlag = flag.String("validity", "", "") + daysFlag = flag.Int("days", 0, "") ) flag.Usage = func() { fmt.Fprint(flag.CommandLine.Output(), shortUsage) @@ -142,10 +164,48 @@ func main() { if *csrFlag != "" && flag.NArg() != 0 { log.Fatalln("ERROR: can't specify extra arguments when using -csr") } + + caValidity := *caValidityFlag + if caValidity == "" { + caValidity = os.Getenv("CA_VALIDITY") + } + if caValidity != "" { + if _, err := parseValidity(caValidity); err != nil { + log.Fatalf("ERROR: invalid CA validity %q: %s\n", caValidity, err) + } + } + + if *daysFlag < 0 { + log.Fatalln("ERROR: -days must be greater than zero") + } + if *daysFlag > 0 && (*certValidityFlag != "" || *validityFlag != "") { + log.Fatalln("ERROR: cannot specify both -days and -validity/-cert-validity") + } + if *certValidityFlag != "" && *validityFlag != "" && *certValidityFlag != *validityFlag { + log.Fatalln("ERROR: cannot specify differing values for -cert-validity and -validity") + } + + certValidity := *certValidityFlag + if certValidity == "" { + certValidity = *validityFlag + } + if certValidity == "" && *daysFlag > 0 { + certValidity = strconv.Itoa(*daysFlag) + "d" + } + if certValidity == "" { + certValidity = os.Getenv("CERT_VALIDITY") + } + if certValidity != "" { + if _, err := parseValidity(certValidity); err != nil { + log.Fatalf("ERROR: invalid certificate validity %q: %s\n", certValidity, err) + } + } + (&mkcert{ installMode: *installFlag, uninstallMode: *uninstallFlag, csrPath: *csrFlag, pkcs12: *pkcs12Flag, ecdsa: *ecdsaFlag, client: *clientFlag, certFile: *certFileFlag, keyFile: *keyFileFlag, p12File: *p12FileFlag, + caValidity: caValidity, certValidity: certValidity, }).Run(flag.Args()) } @@ -157,6 +217,7 @@ type mkcert struct { pkcs12, ecdsa, client bool keyFile, certFile, p12File string csrPath string + caValidity, certValidity string CAROOT string caCert *x509.Certificate diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..a015672e --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.27.1" diff --git a/validity.go b/validity.go new file mode 100644 index 00000000..c564d442 --- /dev/null +++ b/validity.go @@ -0,0 +1,94 @@ +// Copyright 2018 The mkcert Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +type validityDuration struct { + years int + months int + days int + timeDur time.Duration +} + +func (v validityDuration) apply(t time.Time) time.Time { + return t.AddDate(v.years, v.months, v.days).Add(v.timeDur) +} + +var ( + validityPartPattern = regexp.MustCompile(`(?i)^(\d+)\s*([a-z]+)$`) + validityTokenizer = regexp.MustCompile(`(?i)\d+\s*[a-z]+`) +) + +func parseValidity(s string) (validityDuration, error) { + s = strings.TrimSpace(s) + if s == "" { + return validityDuration{}, fmt.Errorf("validity duration cannot be empty") + } + + // Verify that the string is completely covered by valid component tokens + matches := validityTokenizer.FindAllStringIndex(s, -1) + if len(matches) == 0 { + return validityDuration{}, fmt.Errorf("invalid validity format %q (examples: 10y, 2y3m, 365d, 30d, 24h)", s) + } + + // Check for extraneous characters between or around matches + lastIdx := 0 + for _, idx := range matches { + gap := strings.TrimSpace(s[lastIdx:idx[0]]) + if gap != "" { + return validityDuration{}, fmt.Errorf("invalid characters %q in validity %q", gap, s) + } + lastIdx = idx[1] + } + remaining := strings.TrimSpace(s[lastIdx:]) + if remaining != "" { + return validityDuration{}, fmt.Errorf("invalid characters %q in validity %q", remaining, s) + } + + var res validityDuration + for _, idx := range matches { + token := s[idx[0]:idx[1]] + parts := validityPartPattern.FindStringSubmatch(token) + if len(parts) != 3 { + return validityDuration{}, fmt.Errorf("invalid validity component %q", token) + } + + val, err := strconv.Atoi(parts[1]) + if err != nil || val < 0 { + return validityDuration{}, fmt.Errorf("invalid number %q in validity %q", parts[1], s) + } + + unit := strings.ToLower(parts[2]) + switch unit { + case "y", "yr", "yrs", "year", "years": + res.years += val + case "mo", "mon", "mons", "month", "months", "m": + res.months += val + case "d", "day", "days": + res.days += val + case "h", "hr", "hrs", "hour", "hours": + res.timeDur += time.Duration(val) * time.Hour + case "min", "mins", "minute", "minutes": + res.timeDur += time.Duration(val) * time.Minute + case "s", "sec", "secs", "second", "seconds": + res.timeDur += time.Duration(val) * time.Second + default: + return validityDuration{}, fmt.Errorf("unknown unit %q in validity %q (supported units: y, mo/m, d, h, min, s)", unit, s) + } + } + + if res.years == 0 && res.months == 0 && res.days == 0 && res.timeDur == 0 { + return validityDuration{}, fmt.Errorf("validity must be greater than zero") + } + + return res, nil +} diff --git a/validity_test.go b/validity_test.go new file mode 100644 index 00000000..695f3ca7 --- /dev/null +++ b/validity_test.go @@ -0,0 +1,387 @@ +// Copyright 2018 The mkcert Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "crypto/x509" + "encoding/pem" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestParseValidity(t *testing.T) { + tests := []struct { + input string + wantErr bool + checkDur func(validityDuration) bool + description string + }{ + { + input: "10y", + checkDur: func(v validityDuration) bool { + return v.years == 10 && v.months == 0 && v.days == 0 && v.timeDur == 0 + }, + description: "10 years", + }, + { + input: "2y3m", + checkDur: func(v validityDuration) bool { + return v.years == 2 && v.months == 3 && v.days == 0 && v.timeDur == 0 + }, + description: "2 years 3 months", + }, + { + input: "2y 3m", + checkDur: func(v validityDuration) bool { + return v.years == 2 && v.months == 3 && v.days == 0 && v.timeDur == 0 + }, + description: "2 years 3 months with space", + }, + { + input: "2 years 3 months", + checkDur: func(v validityDuration) bool { + return v.years == 2 && v.months == 3 && v.days == 0 && v.timeDur == 0 + }, + description: "2 years 3 months full words", + }, + { + input: "1y6mo15d", + checkDur: func(v validityDuration) bool { + return v.years == 1 && v.months == 6 && v.days == 15 && v.timeDur == 0 + }, + description: "composite 1y6mo15d", + }, + { + input: "825d", + checkDur: func(v validityDuration) bool { + return v.years == 0 && v.months == 0 && v.days == 825 && v.timeDur == 0 + }, + description: "825 days", + }, + { + input: "30d", + checkDur: func(v validityDuration) bool { + return v.years == 0 && v.months == 0 && v.days == 30 && v.timeDur == 0 + }, + description: "30 days", + }, + { + input: "24h", + checkDur: func(v validityDuration) bool { + return v.years == 0 && v.months == 0 && v.days == 0 && v.timeDur == 24*time.Hour + }, + description: "24 hours", + }, + { + input: "90min", + checkDur: func(v validityDuration) bool { + return v.years == 0 && v.months == 0 && v.days == 0 && v.timeDur == 90*time.Minute + }, + description: "90 minutes", + }, + { + input: "30s", + checkDur: func(v validityDuration) bool { + return v.years == 0 && v.months == 0 && v.days == 0 && v.timeDur == 30*time.Second + }, + description: "30 seconds", + }, + // Invalid cases + { + input: "", + wantErr: true, + description: "empty string", + }, + { + input: " ", + wantErr: true, + description: "whitespace only", + }, + { + input: "0d", + wantErr: true, + description: "zero duration", + }, + { + input: "0y0m0d", + wantErr: true, + description: "zero composite", + }, + { + input: "10", + wantErr: true, + description: "missing unit", + }, + { + input: "10x", + wantErr: true, + description: "unknown unit", + }, + { + input: "2y 3x", + wantErr: true, + description: "partially unknown unit", + }, + { + input: "-5d", + wantErr: true, + description: "negative duration", + }, + } + + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + res, err := parseValidity(tt.input) + if (err != nil) != tt.wantErr { + t.Fatalf("parseValidity(%q) err = %v, wantErr = %v", tt.input, err, tt.wantErr) + } + if !tt.wantErr && tt.checkDur != nil && !tt.checkDur(res) { + t.Errorf("parseValidity(%q) returned unexpected result: %+v", tt.input, res) + } + }) + } +} + +func TestApplyValidity(t *testing.T) { + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + v, err := parseValidity("10y") + if err != nil { + t.Fatal(err) + } + expected10y := time.Date(2036, 1, 1, 12, 0, 0, 0, time.UTC) + if got := v.apply(now); !got.Equal(expected10y) { + t.Errorf("apply 10y: got %v, want %v", got, expected10y) + } + + v2, err := parseValidity("2y3m") + if err != nil { + t.Fatal(err) + } + expected2y3m := time.Date(2028, 4, 1, 12, 0, 0, 0, time.UTC) + if got := v2.apply(now); !got.Equal(expected2y3m) { + t.Errorf("apply 2y3m: got %v, want %v", got, expected2y3m) + } + + v3, err := parseValidity("30d") + if err != nil { + t.Fatal(err) + } + expected30d := time.Date(2026, 1, 31, 12, 0, 0, 0, time.UTC) + if got := v3.apply(now); !got.Equal(expected30d) { + t.Errorf("apply 30d: got %v, want %v", got, expected30d) + } +} + +func readCertFromPEM(path string) (*x509.Certificate, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + block, _ := pem.Decode(b) + return x509.ParseCertificate(block.Bytes) +} + +func TestCAAndCertGenerationWithValidity(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "mkcert-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test 1: Generate CA with custom validity 5y and cert with 30d + m := &mkcert{ + CAROOT: tmpDir, + caValidity: "5y", + certValidity: "30d", + certFile: filepath.Join(tmpDir, "test.pem"), + keyFile: filepath.Join(tmpDir, "test-key.pem"), + } + + m.loadCA() + + // Check CA cert validity + caCertPath := filepath.Join(tmpDir, rootName) + caCert, err := readCertFromPEM(caCertPath) + if err != nil { + t.Fatalf("failed to read CA cert: %v", err) + } + + expectedCAExpire := caCert.NotBefore.AddDate(5, 0, 0) + if !caCert.NotAfter.Equal(expectedCAExpire) { + t.Errorf("CA NotAfter: got %v, want %v", caCert.NotAfter, expectedCAExpire) + } + + // Generate leaf cert + m.makeCert([]string{"example.com"}) + + leafCert, err := readCertFromPEM(m.certFile) + if err != nil { + t.Fatalf("failed to read leaf cert: %v", err) + } + + expectedLeafExpire := leafCert.NotBefore.AddDate(0, 0, 30) + if !leafCert.NotAfter.Equal(expectedLeafExpire) { + t.Errorf("Leaf cert NotAfter: got %v, want %v", leafCert.NotAfter, expectedLeafExpire) + } +} + +func TestDefaultValidity(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "mkcert-default-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + m := &mkcert{ + CAROOT: tmpDir, + certFile: filepath.Join(tmpDir, "default.pem"), + keyFile: filepath.Join(tmpDir, "default-key.pem"), + } + + m.loadCA() + + caCert, err := readCertFromPEM(filepath.Join(tmpDir, rootName)) + if err != nil { + t.Fatalf("failed to read CA cert: %v", err) + } + + // Default CA validity is 10 years + now := time.Now() + expectedCAExpire := now.AddDate(10, 0, 0) + if diff := caCert.NotAfter.Sub(expectedCAExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("Default CA NotAfter: got %v, want near %v", caCert.NotAfter, expectedCAExpire) + } + + // Generate leaf cert with defaults + m.makeCert([]string{"default.example.com"}) + + leafCert, err := readCertFromPEM(m.certFile) + if err != nil { + t.Fatalf("failed to read leaf cert: %v", err) + } + + // Default leaf cert validity is 2 years 3 months + expectedLeafExpire := now.AddDate(2, 3, 0) + if diff := leafCert.NotAfter.Sub(expectedLeafExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("Default leaf cert NotAfter: got %v, want near %v", leafCert.NotAfter, expectedLeafExpire) + } +} + +func TestCLIFlagsExecution(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "mkcert-cli-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test with -ca-validity 1y and -days 90 + certPath := filepath.Join(tmpDir, "cli.pem") + keyPath := filepath.Join(tmpDir, "cli-key.pem") + + cmd := exec.Command("go", "run", ".", "-ca-validity", "1y", "-days", "90", "-cert-file", certPath, "-key-file", keyPath, "cli.example.com") + cmd.Env = append(os.Environ(), "CAROOT="+tmpDir) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("CLI command failed: %v, output: %s", err, out) + } + + // Verify CA validity is 1 year + caCert, err := readCertFromPEM(filepath.Join(tmpDir, rootName)) + if err != nil { + t.Fatalf("failed to read CA cert: %v", err) + } + now := time.Now() + expectedCAExpire := now.AddDate(1, 0, 0) + if diff := caCert.NotAfter.Sub(expectedCAExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("CLI CA NotAfter: got %v, want near %v", caCert.NotAfter, expectedCAExpire) + } + + // Verify leaf cert validity is 90 days + leafCert, err := readCertFromPEM(certPath) + if err != nil { + t.Fatalf("failed to read leaf cert: %v", err) + } + expectedLeafExpire := now.AddDate(0, 0, 90) + if diff := leafCert.NotAfter.Sub(expectedLeafExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("CLI leaf NotAfter: got %v, want near %v", leafCert.NotAfter, expectedLeafExpire) + } +} + +func TestCLIFlagsErrorHandling(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "mkcert-err-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Test invalid ca-validity + cmd := exec.Command("go", "run", ".", "-ca-validity", "invalid", "example.com") + cmd.Env = append(os.Environ(), "CAROOT="+tmpDir) + if err := cmd.Run(); err == nil { + t.Error("expected error for invalid -ca-validity, got success") + } + + // Test conflicting -days and -cert-validity + cmd = exec.Command("go", "run", ".", "-days", "30", "-cert-validity", "60d", "example.com") + cmd.Env = append(os.Environ(), "CAROOT="+tmpDir) + if err := cmd.Run(); err == nil { + t.Error("expected error for conflicting -days and -cert-validity, got success") + } + + // Test negative -days + cmd = exec.Command("go", "run", ".", "-days", "-5", "example.com") + cmd.Env = append(os.Environ(), "CAROOT="+tmpDir) + if err := cmd.Run(); err == nil { + t.Error("expected error for negative -days, got success") + } +} + +func TestCLIEnvironmentVariables(t *testing.T) { + tmpDir, err := ioutil.TempDir("", "mkcert-env-test-*") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + certPath := filepath.Join(tmpDir, "env.pem") + keyPath := filepath.Join(tmpDir, "env-key.pem") + + cmd := exec.Command("go", "run", ".", "-cert-file", certPath, "-key-file", keyPath, "env.example.com") + cmd.Env = append(os.Environ(), + "CAROOT="+tmpDir, + "CA_VALIDITY=3y", + "CERT_VALIDITY=45d", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v, output: %s", err, out) + } + + // Check CA has 3y validity + caCert, err := readCertFromPEM(filepath.Join(tmpDir, rootName)) + if err != nil { + t.Fatalf("failed to read CA cert: %v", err) + } + now := time.Now() + expectedCAExpire := now.AddDate(3, 0, 0) + if diff := caCert.NotAfter.Sub(expectedCAExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("Env CA NotAfter: got %v, want near %v", caCert.NotAfter, expectedCAExpire) + } + + // Check leaf has 45d validity + leafCert, err := readCertFromPEM(certPath) + if err != nil { + t.Fatalf("failed to read leaf cert: %v", err) + } + expectedLeafExpire := now.AddDate(0, 0, 45) + if diff := leafCert.NotAfter.Sub(expectedLeafExpire); diff < -5*time.Second || diff > 5*time.Second { + t.Errorf("Env leaf NotAfter: got %v, want near %v", leafCert.NotAfter, expectedLeafExpire) + } +}