diff --git a/build/Dockerfile-gui b/build/Dockerfile-gui index 6c53493..7f1a2ee 100644 --- a/build/Dockerfile-gui +++ b/build/Dockerfile-gui @@ -32,6 +32,7 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates \ tzdata \ + unzip \ zip \ && rm -rf /var/lib/apt/lists/* diff --git a/control_do.sh b/control_do.sh index 76f8246..143ed06 100755 --- a/control_do.sh +++ b/control_do.sh @@ -58,8 +58,10 @@ setup_nginx_data() { [ -e /opt/labca/data/root-ca.crl ] && cp /opt/labca/data/root-ca.crl crl/ || true [ -e /opt/labca/data/root-ca.pem ] && cp /opt/labca/data/root-ca.pem certs/ || true + [ -e /opt/labca/data/root-ca.pem ] && ln -sf root-ca.pem certs/test-root.pem || true [ -e /opt/labca/data/root-ca.der ] && cp /opt/labca/data/root-ca.der certs/ || true [ -e /opt/labca/data/issuer/ca-int.pem ] && cp /opt/labca/data/issuer/ca-int.pem certs/ || true + [ -e /opt/labca/data/issuer/ca-int.pem ] && ln -sf ca-int.pem certs/test-ca.pem || true [ -e /opt/labca/data/issuer/ca-int.pem ] && cp /opt/labca/data/issuer/ca-int.der certs/ || true if [ ! -e /etc/nginx/ssl/labca_cert.pem ]; then diff --git a/gui/apply b/gui/apply index 2984ab3..21c62b9 100755 --- a/gui/apply +++ b/gui/apply @@ -8,9 +8,10 @@ dataDir="$baseDir/data" export PKI_ROOT_CERT_BASE="$dataDir/root-ca" export PKI_INT_CERT_BASE="$dataDir/issuer/ca-int" -cd /opt/wwwstatic +cd /opt/boulder/labca +$baseDir/apply-boulder -$baseDir/apply-nginx +cd /opt/wwwstatic if [ -e "$PKI_ROOT_CERT_BASE.crl" ]; then cp $PKI_ROOT_CERT_BASE.crl crl/ @@ -18,10 +19,10 @@ else echo "WARNING: no Root CRL file present - please upload one from the manage page" fi cp $PKI_ROOT_CERT_BASE.pem certs/ +ln -sf root-ca.pem certs/test-root.pem cp $PKI_ROOT_CERT_BASE.der certs/ cp $PKI_INT_CERT_BASE.pem certs/ +ln -sf ca-int.pem certs/test-ca.pem cp $PKI_INT_CERT_BASE.der certs/ - -cd /opt/boulder/labca -$baseDir/apply-boulder +$baseDir/apply-nginx diff --git a/gui/apply-boulder b/gui/apply-boulder index 9d912ad..9b33f8d 100755 --- a/gui/apply-boulder +++ b/gui/apply-boulder @@ -2,7 +2,7 @@ set -e -baseDir=$(dirname $0) +baseDir=$(cd $(dirname $0) && pwd) dataDir="$baseDir/data" PKI_DNS=$(grep dns $dataDir/config.json | perl -p0e 's/.*?:\s+(.*)/\1/' | sed -e 's/\",//g' | sed -e 's/\"//g') diff --git a/gui/apply-nginx b/gui/apply-nginx index fdc4c56..35c9489 100755 --- a/gui/apply-nginx +++ b/gui/apply-nginx @@ -2,7 +2,7 @@ set -e -baseDir=$(dirname $0) +baseDir=$(cd $(dirname $0) && pwd) dataDir="$baseDir/data" PKI_WEB_TITLE=$(grep web_title $dataDir/config.json | sed -e 's/.*:[ ]*//' | sed -e 's/\",//g' | sed -e 's/\"//g') diff --git a/gui/certificate.go b/gui/certificate.go index 00ee587..1322a97 100644 --- a/gui/certificate.go +++ b/gui/certificate.go @@ -2,25 +2,36 @@ package main import ( "crypto/rand" + "crypto/x509" + "encoding/pem" "errors" "fmt" "io" + "io/fs" + "math" "math/big" "mime/multipart" "os" "os/exec" "path/filepath" + "regexp" "runtime/debug" + "strconv" "strings" + "time" ) // CertificateInfo contains all data related to a certificate (file) type CertificateInfo struct { IsRoot bool + IsFirst bool KeyTypes map[string]string KeyType string CreateType string IsRootGenerated bool + RootSubject string + RootEnddate string + NumDays int Country string Organization string @@ -115,7 +126,7 @@ func reportError(param interface{}) error { fmt.Println(strings.Join(lines, "\n")) - res := errors.New("Error! See LabCA logs for details") + res := errors.New("error: see LabCA logs for details") switch v := param.(type) { case error: res = errors.New("Error (" + v.Error() + ")! See LabCA logs for details") @@ -151,7 +162,7 @@ func preCreateTasks(path string) error { return reportError(err) } - if _, err := os.Stat(path + "serial"); os.IsNotExist(err) { + if _, err := os.Stat(path + "serial"); errors.Is(err, fs.ErrNotExist) { s, err := getRandomSerial() if err != nil { return err @@ -160,7 +171,7 @@ func preCreateTasks(path string) error { return err } } - if _, err := os.Stat(path + "crlnumber"); os.IsNotExist(err) { + if _, err := os.Stat(path + "crlnumber"); errors.Is(err, fs.ErrNotExist) { if err = os.WriteFile(path+"crlnumber", []byte("1000\n"), 0644); err != nil { return err } @@ -173,6 +184,23 @@ func preCreateTasks(path string) error { return nil } +func updateRootCRLDays(filename string, numDays int) error { + read, err := os.ReadFile(filename) + if err != nil { + fmt.Println(err) + return errors.New("could not read '" + filename + "': " + err.Error()) + } + re := regexp.MustCompile(`(default_crl_days\s*=).*`) + res := re.ReplaceAll(read, []byte("$1 "+strconv.Itoa(numDays))) + + if err = os.WriteFile(filename, res, 0640); err != nil { + fmt.Println(err) + return errors.New("could not write '" + filename + "': " + err.Error()) + } + + return nil +} + // Generate a key and certificate file for the data from this CertificateInfo func (ci *CertificateInfo) Generate(path string, certBase string) error { // 1. Generate key @@ -217,14 +245,18 @@ func (ci *CertificateInfo) Generate(path string, certBase string) error { subject = strings.Replace(subject, " ", "\\\\", -1) if ci.IsRoot { - if _, err := exeCmd("openssl req -config " + path + "openssl.cnf -days 3650 -new -utf8 -x509 -extensions v3_ca -subj " + subject + " -key " + path + certBase + ".key -out " + path + certBase + ".pem"); err != nil { + if _, err := exeCmd("openssl req -config " + path + "openssl.cnf -days " + strconv.Itoa(ci.NumDays) + " -new -utf8 -x509 -extensions v3_ca -subj " + subject + " -key " + path + certBase + ".key -out " + path + certBase + ".pem"); err != nil { + return reportError(err) + } + + if err := updateRootCRLDays(path+"openssl.cnf", ci.NumDays); err != nil { return reportError(err) } } else { if _, err := exeCmd("openssl req -config " + path + "openssl.cnf -new -utf8 -subj " + subject + " -key " + path + certBase + ".key -out " + path + certBase + ".csr"); err != nil { return reportError(err) } - if out, err := exeCmd("openssl ca -config " + path + "../openssl.cnf -extensions v3_intermediate_ca -days 3600 -md sha384 -notext -batch -in " + path + certBase + ".csr -out " + path + certBase + ".pem"); err != nil { + if out, err := exeCmd("openssl ca -config " + path + "../openssl.cnf -extensions v3_intermediate_ca -days " + strconv.Itoa(ci.NumDays) + " -md sha384 -notext -batch -in " + path + certBase + ".csr -out " + path + certBase + ".pem"); err != nil { if strings.Contains(string(out), "root-ca.key for reading, No such file or directory") { return errors.New("NO_ROOT_KEY") } @@ -321,7 +353,7 @@ func (ci *CertificateInfo) Import(tmpDir string, tmpKey string, tmpCert string) return err } - } else if contentType == "application/zip" { + } else if contentType == "application/zip" || contentType == "application/x-zip-compressed" { err := ci.ImportZip(tmpFile, tmpDir) if err != nil { return err @@ -377,7 +409,7 @@ func (ci *CertificateInfo) Upload(tmpKey string, tmpCert string) error { func parseSubjectDn(subject string) map[string]string { trackerResultMap := map[string]string{"C=": "", "C =": "", "O=": "", "O =": "", "CN=": "", "CN =": "", "OU=": "", "OU =": ""} - for tracker, _ := range trackerResultMap { + for tracker := range trackerResultMap { index := strings.Index(subject, tracker) if index < 0 { @@ -449,7 +481,7 @@ func (ci *CertificateInfo) ImportCerts(path string, rootCert string, rootKey str } keyFileExists := true - if _, err := os.Stat(rootKey); os.IsNotExist(err) { + if _, err := os.Stat(rootKey); errors.Is(err, fs.ErrNotExist) { keyFileExists = false } if keyFileExists { @@ -499,7 +531,7 @@ func (ci *CertificateInfo) ImportCerts(path string, rootCert string, rootKey str return errors.New("issuer not issued by our Root CA") } - r, err = exeCmd("openssl verify -CAfile data/root-ca.pem " + issuerCert) + _, err = exeCmd("openssl verify -CAfile data/root-ca.pem " + issuerCert) if err != nil { return errors.New("could not verify that issuer was issued by our Root CA") } @@ -524,7 +556,7 @@ func (ci *CertificateInfo) MoveFiles(path string, rootCert string, rootKey strin } if rootKey != "" { keyFileExists := true - if _, err := os.Stat(rootKey); os.IsNotExist(err) { + if _, err := os.Stat(rootKey); errors.Is(err, fs.ErrNotExist) { keyFileExists = false } if keyFileExists { @@ -564,26 +596,60 @@ func (ci *CertificateInfo) Extract(path string, certBase string, tmpDir string, rootCert = filepath.Join(tmpDir, "root-ca.pem") rootKey = filepath.Join(tmpDir, "root-ca.key") - if _, err := os.Stat(rootCert); os.IsNotExist(err) { - return errors.New("file does not contain root-ca.pem") + if _, err := os.Stat(rootCert); errors.Is(err, fs.ErrNotExist) { + altCert := filepath.Join(tmpDir, "test-root.pem") + if _, err = os.Stat(altCert); err == nil { + if _, err := exeCmd("mv " + altCert + " " + rootCert); err != nil { + return err + } + } + + altKey := filepath.Join(tmpDir, "test-root.key") + if _, err = os.Stat(altKey); err == nil { + if _, err := exeCmd("mv " + altKey + " " + rootKey); err != nil { + return err + } + } + + if _, err := os.Stat(rootCert); errors.Is(err, fs.ErrNotExist) { + return errors.New("file does not contain root-ca.pem") + } } } issuerCert = filepath.Join(tmpDir, "ca-int.pem") issuerKey = filepath.Join(tmpDir, "ca-int.key") - if _, err := os.Stat(issuerCert); os.IsNotExist(err) { + if _, err := os.Stat(issuerCert); errors.Is(err, fs.ErrNotExist) { if ci.IsRoot { issuerCert = "" } else { - return errors.New("file does not contain ca-int.pem") + altCert := filepath.Join(tmpDir, "test-ca.pem") + if _, err = os.Stat(altCert); err == nil { + if _, err := exeCmd("mv " + altCert + " " + issuerCert); err != nil { + return err + } + } + + if _, err := os.Stat(issuerCert); errors.Is(err, fs.ErrNotExist) { + return errors.New("file does not contain ca-int.pem") + } } } - if _, err := os.Stat(issuerKey); os.IsNotExist(err) { + if _, err := os.Stat(issuerKey); errors.Is(err, fs.ErrNotExist) { if ci.IsRoot || wasCSR { issuerKey = "" } else { - return errors.New("file does not contain ca-int.key") + altKey := filepath.Join(tmpDir, "test-ca.key") + if _, err = os.Stat(altKey); err == nil { + if _, err := exeCmd("mv " + altKey + " " + issuerKey); err != nil { + return err + } + } + + if _, err := os.Stat(issuerKey); errors.Is(err, fs.ErrNotExist) { + return errors.New("file does not contain ca-int.key") + } } } @@ -598,6 +664,29 @@ func (ci *CertificateInfo) Extract(path string, certBase string, tmpDir string, return err } + // Extract enddate to determine what the default CRL validity should be + if ci.IsRoot { + certFile := path + filepath.Base(rootCert) + read, err := os.ReadFile(certFile) + if err != nil { + fmt.Println(err) + return errors.New("could not read '" + certFile + "': " + err.Error()) + } + block, _ := pem.Decode(read) + if block == nil || block.Type != "CERTIFICATE" { + fmt.Println(block) + return errors.New("failed to decode PEM block containing certificate") + } + crt, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return err + } + numDays := time.Until(crt.NotAfter).Hours() / 24 + if err := updateRootCRLDays("data/openssl.cnf", int(math.Ceil(numDays))); err != nil { + return err + } + } + return nil } @@ -646,7 +735,7 @@ func (ci *CertificateInfo) Create(path string, certBase string, wasCSR bool) err return fmt.Errorf("unknown CreateType") } - // This is shared between pfx/zip upload and pem text upload + // This is shared between pfx/zip import and pem text upload if ci.CreateType != "generate" { err := ci.Extract(path, certBase, tmpDir, wasCSR) if err != nil { @@ -660,7 +749,7 @@ func (ci *CertificateInfo) Create(path string, certBase string, wasCSR bool) err if ci.IsRoot { keyFileExists := true - if _, err := os.Stat(path + certBase + ".key"); os.IsNotExist(err) { + if _, err := os.Stat(path + certBase + ".key"); errors.Is(err, fs.ErrNotExist) { keyFileExists = false } if keyFileExists { @@ -687,6 +776,48 @@ func postCreateTasks(path string, certBase string, isRoot bool) error { return nil } +func storeRootKey(path, certName, tmpDir, keyData, passphrase string) (bool, string) { + tmpKey := filepath.Join(tmpDir, certName+".key") + + if err := os.WriteFile(tmpKey, []byte(keyData), 0600); err != nil { + return false, err.Error() + } + + if passphrase != "" { + pwd := "pass:" + strings.Replace(passphrase, " ", "\\\\", -1) + + if out, err := exeCmd("openssl pkey -passin " + pwd + " -in " + tmpKey + " -out " + tmpKey + "-out"); err != nil { + if strings.Contains(string(out), ":bad decrypt:") { + return false, "Incorrect password" + } + + return false, "Unable to load Root CA key" + } + + if _, err := exeCmd("mv " + tmpKey + "-out " + tmpKey); err != nil { + return false, err.Error() + } + } + + modKey, err := exeCmd("openssl rsa -noout -modulus -in " + tmpKey) + if err != nil { + return false, "Not a private key" + } + modCert, err := exeCmd("openssl x509 -noout -modulus -in " + path + certName + ".pem") + if err != nil { + return false, "Unable to load Root CA certificate" + } + if string(modKey) != string(modCert) { + return false, "Key does not match the Root CA certificate" + } + + if _, err := exeCmd("mv " + tmpKey + " " + path); err != nil { + return false, err.Error() + } + + return true, "" +} + func (ci *CertificateInfo) StoreRootKey(path string) bool { if ci.Errors == nil { ci.Errors = make(map[string]string) @@ -704,54 +835,13 @@ func (ci *CertificateInfo) StoreRootKey(path string) bool { defer os.RemoveAll(tmpDir) - tmpKey := filepath.Join(tmpDir, "root-ca.key") - - if err := os.WriteFile(tmpKey, []byte(ci.Key), 0644); err != nil { - ci.Errors["Modal"] = err.Error() - return false - } - - if ci.Passphrase != "" { - pwd := "pass:" + strings.Replace(ci.Passphrase, " ", "\\\\", -1) - - if out, err := exeCmd("openssl pkey -passin " + pwd + " -in " + tmpKey + " -out " + tmpKey + "-out"); err != nil { - if strings.Contains(string(out), ":bad decrypt:") { - ci.Errors["Modal"] = "Incorrect password" - return false - } - - ci.Errors["Modal"] = "Unable to load Root CA key" - return false - } - - if _, err := exeCmd("mv " + tmpKey + "-out " + tmpKey); err != nil { - ci.Errors["Modal"] = err.Error() - return false - } - } - - modKey, err := exeCmd("openssl rsa -noout -modulus -in " + tmpKey) - if err != nil { - ci.Errors["Modal"] = "Not a private key" - return false - } - modCert, err := exeCmd("openssl x509 -noout -modulus -in " + path + "root-ca.pem") - if err != nil { - ci.Errors["Modal"] = "Unable to load Root CA certificate" - return false - } - if string(modKey) != string(modCert) { - ci.Errors["Modal"] = "Key does not match the Root CA certificate" - return false - } - - if _, err := exeCmd("mv " + tmpKey + " " + path); err != nil { - ci.Errors["Modal"] = err.Error() + certBase := "root-ca" + if res, newError := storeRootKey(path, certBase, tmpDir, ci.Key, ci.Passphrase); !res { + ci.Errors["Modal"] = newError return false } // Create root CRL file now that we have the key - certBase := "root-ca" if _, err := exeCmd("openssl ca -config " + path + "openssl.cnf -gencrl -keyfile " + path + certBase + ".key -cert " + path + certBase + ".pem -out " + path + certBase + ".crl"); err != nil { fmt.Printf("StoreRootKey: %s\n", err.Error()) return false @@ -808,6 +898,147 @@ func (ci *CertificateInfo) StoreCRL(path string) bool { return true } +func renewCertificate(certname string, days int, rootname string, rootkeyfile string, passphrase string) error { + certFile := locateFile(certname + ".pem") + path := filepath.Dir(certFile) + "/" + certBase := path + certname + keyFile := certBase + ".key" + rootCert := "" + rootKey := keyFile + + if strings.HasPrefix(certname, "ca-int") || strings.HasPrefix(certname, "test-ca") { + rootCert = locateFile(rootname + ".pem") + rootKey = locateFile(rootname + ".key") + + // Make sure openssl allows us to add certificates with the same subject + attrFile := "data/index.txt.attr" + read, err := os.ReadFile(attrFile) + if err != nil { + fmt.Println(err) + return errors.New("could not read index.txt.attr file: " + err.Error()) + } + re := regexp.MustCompile(`unique_subject = yes`) + res := re.ReplaceAll(read, []byte("unique_subject = no")) + + if string(res) != string(read) { + if err = os.WriteFile(attrFile, res, 0640); err != nil { + fmt.Println(err) + return errors.New("could not write index.txt.attr file: " + err.Error()) + } + } + } + + tmpDir, err := os.MkdirTemp("", "labca") + if err != nil { + return err + } + + defer os.RemoveAll(tmpDir) + + if _, err := os.Stat(rootKey); errors.Is(err, fs.ErrNotExist) { + if rootkeyfile == "" { + return errors.New("NO_ROOT_KEY") + } else { + if res, newError := storeRootKey(path, certname, tmpDir, rootkeyfile, passphrase); !res { + return errors.New("NO_ROOT_KEY:" + newError) + } + defer exeCmd("rm " + rootKey) + } + } + + r, err := exeCmd("openssl x509 -noout -subject -nameopt utf8 -in " + certFile) + if err != nil { + return err + } + subject := string(r[8 : len(r)-1]) + subject = "/" + strings.ReplaceAll(subject, ", ", "/") + subject = strings.Replace(subject, " ", "\\\\", -1) + + if rootKey == keyFile { + if _, err := exeCmd("openssl req -config data/openssl.cnf -days " + strconv.Itoa(days) + " -new -utf8 -x509 -extensions v3_ca -subj " + subject + + " -key " + keyFile + " -out " + certFile + ".tmp"); err != nil { + return reportError(err) + } + + if err := updateRootCRLDays("data/openssl.cnf", days); err != nil { + return reportError(err) + } + + if _, err := exeCmd("openssl ca -config data/openssl.cnf -gencrl -keyfile " + keyFile + " -cert " + certFile + ".tmp -out " + certBase + ".crl"); err != nil { + return reportError(err) + } + + } else { + if _, err := exeCmd("openssl req -config data/issuer/openssl.cnf -new -utf8 -subj " + subject + " -key " + keyFile + " -out " + certBase + ".csr"); err != nil { + return reportError(err) + } + if out, err := exeCmd("openssl ca -config data/openssl.cnf -cert " + rootCert + " -keyfile " + rootKey + " -extensions v3_intermediate_ca -days " + + strconv.Itoa(days) + " -md sha384 -notext -batch -in " + certBase + ".csr -out " + certFile + ".tmp"); err != nil { + if strings.Contains(string(out), ".key for reading, No such file or directory") { + fmt.Println(out) + return errors.New("NO_ROOT_KEY") + } + return reportError(err) + } + + } + if _, err := exeCmd("mv " + certFile + ".tmp " + certFile); err != nil { + return reportError(err) + } + + // TODO: need to get rid of this! + if rootKey == keyFile { + if strings.HasPrefix(certname, "test-root") { + dataFile := locateFile("root-ca.pem") + if _, err := exeCmd("cp " + certFile + " " + dataFile); err != nil { + fmt.Println(err) + } + dataKeyFile := strings.TrimSuffix(dataFile, filepath.Ext(dataFile)) + ".key" + if _, err := exeCmd("cp " + keyFile + " " + dataKeyFile); err != nil { + fmt.Println(err) + } + crlFile := strings.TrimSuffix(dataFile, filepath.Ext(dataFile)) + ".crl" + if _, err := exeCmd("cp " + certBase + ".crl " + crlFile); err != nil { + fmt.Println(err) + } + } + } else { + if strings.HasPrefix(certname, "test-ca") { + dataFile := locateFile("ca-int.pem") + if _, err := exeCmd("cp " + certFile + " " + dataFile); err != nil { + fmt.Println(err) + } + dataKeyFile := strings.TrimSuffix(dataFile, filepath.Ext(dataFile)) + ".key" + if _, err := exeCmd("cp " + keyFile + " " + dataKeyFile); err != nil { + fmt.Println(err) + } + } + } + + return nil +} + +func locateFileIn(path, name string) string { + if _, err := os.Stat(path + name); err == nil { + return path + name + } + return "" +} + +// TODO: sort out the file naming/locations properly to not need this and be future proof!! +// Most is found in /opt/boulder/ and some in /opt/boulder/labca/ +func locateFile(name string) string { + + for _, path := range []string{"", "data/", "data/issuer", "/go/src/labca/data/", "/go/src/labca/data/issuer/", "labca/", "/opt/boulder/", "/opt/boulder/labca/"} { + if res := locateFileIn(path, name); res != "" { + return res + } + } + + fmt.Printf("WARNING: could not find '%s'!\n", name) + return "" +} + func exeCmd(cmd string) ([]byte, error) { parts := strings.Fields(cmd) for i := 0; i < len(parts); i++ { @@ -821,4 +1052,4 @@ func exeCmd(cmd string) ([]byte, error) { fmt.Print(fmt.Sprint(err) + ": " + string(out)) } return out, err -} +} \ No newline at end of file diff --git a/gui/chains.go b/gui/chains.go new file mode 100644 index 0000000..3e00bc8 --- /dev/null +++ b/gui/chains.go @@ -0,0 +1,339 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/viper" +) + +const caaConfFile = "/opt/boulder/labca/config/ca-a.json" +const cabConfFile = "/opt/boulder/labca/config/ca-b.json" +const wfeConfFile = "/opt/boulder/labca/config/wfe2.json" + +// From boulder: cmd/boulder-wfe2/main.go +type WFEConfig struct { + WFE struct { + Chains [][]string `validate:"required,min=1,dive,min=2,dive,required"` + } +} + +// From boulder: issuance/issuer.go +type IssuerLoc struct { + ConfigFile string `validate:"required_without_all=PKCS11 File" json:"configFile"` + CertFile string `validate:"required" json:"certFile,omitempty"` + NumSessions int `json:"numSessions"` +} + +// From boulder: issuance/issuer.go +type IssuerConfig struct { + UseForRSALeaves bool `json:"useForRSALeaves"` + UseForECDSALeaves bool `json:"useForECDSALeaves"` + + IssuerURL string `validate:"required,url" json:"issuerURL,omitempty"` + OCSPURL string `validate:"required,url" json:"ocspURL,omitempty"` + CRLURL string `validate:"omitempty,url" json:"crlURL,omitempty"` + + Location IssuerLoc `json:"location,omitempty"` +} + +// From boulder: cmd/boulder-ca/main.go but deconstructed +type Issuance struct { + Issuers []IssuerConfig `validate:"min=1,dive" json:"issuers"` +} +type CA struct { + Issuance Issuance `json:"issuance"` +} +type CAConfig struct { + CA CA `json:"ca"` +} + +// CertDetails contains info about each certificate for use in the GUI +type CertDetails struct { + CertFile string + BaseName string + Subject string + IsRoot bool + UseForRSA bool + UseForECDSA bool + NotAfter string + Details string +} + +type CertChain struct { + RootCert CertDetails + IssuerCerts []CertDetails +} + +func getCertFileDetails(certFile string) (string, error) { + var details string + + res, err := exeCmd("openssl x509 -noout -text -nameopt utf8 -in " + certFile) + if err != nil { + fmt.Println("cannot get details from '" + certFile + "': " + fmt.Sprint(err)) + return "", err + } + details = string(res) + + return details, nil +} + +func getCertFileNotAFter(certFile string) (string, error) { + var notafter string + + res, err := exeCmd("openssl x509 -noout -enddate -nameopt utf8 -in " + certFile) + if err != nil { + fmt.Println("cannot get enddate from '" + certFile + "': " + fmt.Sprint(err)) + return "", err + } + if len(res) <= 9 { + fmt.Println("enddate of '" + certFile + "'does not start with 'notAfter='") + return "", errors.New("enddate of '" + certFile + "'does not start with 'notAfter='") + } + notafter = string(res[9 : len(res)-1]) + return notafter, nil +} + +func getCertFileSubject(certFile string) (string, error) { + var subject string + + res, err := exeCmd("openssl x509 -noout -subject -nameopt utf8 -in " + certFile) + if err != nil { + fmt.Println("cannot get subject from '" + certFile + "': " + fmt.Sprint(err)) + return "", err + } + if len(res) <= 8 { + fmt.Println("subject of '" + certFile + "'does not start with 'subject='") + return "", errors.New("subject of '" + certFile + "'does not start with 'subject='") + } + subject = string(res[8 : len(res)-1]) + return subject, nil +} + +func getRawCAChains() []IssuerConfig { + caConf, err := os.Open(caaConfFile) + if err != nil { + fmt.Println(err) + return nil + } + defer caConf.Close() + + byteValue, _ := io.ReadAll(caConf) + + var result CAConfig + json.Unmarshal([]byte(byteValue), &result) + + return result.CA.Issuance.Issuers +} + +func enhanceChains(chains []CertChain) []CertChain { + rawChains := getRawCAChains() + + for i := 0; i < len(rawChains); i++ { + for k := 0; k < len(chains); k++ { + for n := 0; n < len(chains[k].IssuerCerts); n++ { + if chains[k].IssuerCerts[n].CertFile == rawChains[i].Location.CertFile { + chains[k].IssuerCerts[n].UseForRSA = rawChains[i].UseForRSALeaves + chains[k].IssuerCerts[n].UseForECDSA = rawChains[i].UseForECDSALeaves + certFile := locateFile(rawChains[i].Location.CertFile) + if d, err := getCertFileDetails(certFile); err == nil { + chains[k].IssuerCerts[n].Details = d + } + if na, err := getCertFileNotAFter(certFile); err == nil { + chains[k].IssuerCerts[n].NotAfter = na + } + if s, err := getCertFileSubject(certFile); err == nil { + chains[k].IssuerCerts[n].Subject = s + } + } + } + + if chains[k].RootCert.Subject == "" { + certFile := locateFile(chains[k].RootCert.CertFile) + if d, err := getCertFileDetails(certFile); err == nil { + chains[k].RootCert.Details = d + } + if na, err := getCertFileNotAFter(certFile); err == nil { + chains[k].RootCert.NotAfter = na + } + if s, err := getCertFileSubject(certFile); err == nil { + chains[k].RootCert.Subject = s + } + } + } + } + + return chains +} + +func getRawWFEChains() [][]string { + wfeConf, err := os.Open(wfeConfFile) + if err != nil { + fmt.Println(err) + return nil + } + defer wfeConf.Close() + + byteValue, _ := io.ReadAll(wfeConf) + + var result WFEConfig + json.Unmarshal([]byte(byteValue), &result) + + return result.WFE.Chains +} + +func getChains() []CertChain { + var chains []CertChain + + rawChains := getRawWFEChains() + + for i := 0; i < len(rawChains); i++ { + chain := rawChains[i] + issuer := chain[0] + root := chain[1] + + var certChain CertChain + cIdx := -1 + for k := 0; k < len(chains); k++ { + if chains[k].RootCert.CertFile == root { + certChain = chains[k] + cIdx = k + } + } + + if cIdx < 0 { + base := filepath.Base(root) + base = strings.TrimSuffix(base, filepath.Ext(base)) + certChain = CertChain{RootCert: CertDetails{ + CertFile: root, + BaseName: base, + IsRoot: true, + }} + chains = append(chains, certChain) + cIdx = len(chains) - 1 + } + + base := filepath.Base(issuer) + base = strings.TrimSuffix(base, filepath.Ext(base)) + certChain.IssuerCerts = append(certChain.IssuerCerts, CertDetails{ + CertFile: issuer, + BaseName: base, + IsRoot: false, + }) + + chains[cIdx] = certChain + } + + chains = enhanceChains(chains) + + return chains +} + +func setUseForLeavesFile(filename, forRSA, forECDSA string) error { + caConf, err := os.Open(filename) + if err != nil { + fmt.Println(err) + return errors.New("could not open config file: " + err.Error()) + } + defer caConf.Close() + + byteValue, _ := io.ReadAll(caConf) + + var result CAConfig + if err = json.Unmarshal([]byte(byteValue), &result); err != nil { + return errors.New("could not parse config file: " + err.Error()) + } + + // Make sure that the named certificate(s) exist + foundRSA := false + foundECDSA := false + for i := 0; i < len(result.CA.Issuance.Issuers); i++ { + if strings.Contains(result.CA.Issuance.Issuers[i].Location.CertFile, forRSA) { + foundRSA = true + } + if strings.Contains(result.CA.Issuance.Issuers[i].Location.CertFile, forECDSA) { + foundECDSA = true + } + } + if !foundRSA { + return errors.New("certificate '" + forRSA + "' not found in ca file") + } + if !foundECDSA { + return errors.New("certificate '" + forECDSA + "' not found in ca file") + } + + // Now set the flags for the named certificate(s) + for i := 0; i < len(result.CA.Issuance.Issuers); i++ { + if forRSA != "" { + result.CA.Issuance.Issuers[i].UseForRSALeaves = strings.Contains(result.CA.Issuance.Issuers[i].Location.CertFile, forRSA) + } + if forECDSA != "" { + result.CA.Issuance.Issuers[i].UseForECDSALeaves = strings.Contains(result.CA.Issuance.Issuers[i].Location.CertFile, forECDSA) + } + } + + // Write the modified data back to file, using regex magic to replace only the issuers list... + if jsonString, err := json.MarshalIndent(result, "", "\t"); err == nil { + re := regexp.MustCompile(`(?s).*"issuers": \[(.*?)\s*\].*`) + iss := re.ReplaceAll(jsonString, []byte("$1")) + + read, err := os.ReadFile(filename) + if err != nil { + fmt.Println(err) + return errors.New("could not read config file: " + err.Error()) + } + re = regexp.MustCompile(`(?s)(\s*"issuers": \[).*?(\s*\])`) + res := re.ReplaceAll(read, []byte("$1"+string(iss)+"$2")) + + if err = os.WriteFile(filename, res, 0640); err != nil { + fmt.Println(err) + return errors.New("could not write config file: " + err.Error()) + } + } else { + return errors.New("could not convert json data: " + err.Error()) + } + + return nil +} + +func setUseForLeaves(forRSA, forECDSA string) error { + if err := exec.Command("cp", "-f", caaConfFile, caaConfFile+"_BAK").Run(); err != nil { + return errors.New("could not create ca-a backup file: " + err.Error()) + } + if err := exec.Command("cp", "-f", cabConfFile, cabConfFile+"_BAK").Run(); err != nil { + return errors.New("could not create ca-b backup file: " + err.Error()) + } + + if err := setUseForLeavesFile(caaConfFile, forRSA, forECDSA); err != nil { + exec.Command("mv", caaConfFile+"_BAK", caaConfFile).Run() + exec.Command("mv", cabConfFile+"_BAK", cabConfFile).Run() + return err + } + if err := setUseForLeavesFile(cabConfFile, forRSA, forECDSA); err != nil { + exec.Command("mv", caaConfFile+"_BAK", caaConfFile).Run() + exec.Command("mv", cabConfFile+"_BAK", cabConfFile).Run() + return err + } + + exec.Command("rm", caaConfFile+"_BAK").Run() + exec.Command("rm", cabConfFile+"_BAK").Run() + + if forRSA != "" { + viper.Set("certs.issuerRSA", forRSA) + } + if forECDSA != "" { + viper.Set("certs.issuerECDSA", forECDSA) + } + if forRSA != "" || forECDSA != "" { + viper.WriteConfig() + } + + return nil +} \ No newline at end of file diff --git a/gui/main.go b/gui/main.go index 9268973..aa6c2b3 100644 --- a/gui/main.go +++ b/gui/main.go @@ -18,7 +18,7 @@ import ( "fmt" "html/template" "io" - "io/ioutil" + "io/fs" "log" "math" "math/big" @@ -497,6 +497,8 @@ func _sendCmdOutput(w http.ResponseWriter, r *http.Request, cmd string) { out, err := exec.Command(head, parts...).Output() if err != nil { + fmt.Println(err) + fmt.Println(out) errorHandler(w, r, err, http.StatusInternalServerError) return } @@ -963,49 +965,31 @@ func _emailSendHandler(w http.ResponseWriter, r *http.Request) { } func _exportHandler(w http.ResponseWriter, r *http.Request) { - basename := "certificates" - if r.Form.Get("root") != "true" { - basename = "issuer" - } - if r.Form.Get("issuer") != "true" { - basename = "root" - } + certname := r.Form.Get("certname") + + certFile := locateFile(certname + ".pem") + keyFile := strings.TrimSuffix(certFile, filepath.Ext(certFile)) + ".key" if r.Form.Get("type") == "pfx" { w.Header().Set("Content-Type", "application/x-pkcs12") - w.Header().Set("Content-Disposition", "attachment; filename=labca_"+basename+".pfx") + w.Header().Set("Content-Disposition", "attachment; filename=labca_"+certname+".pfx") - var certBase string - if basename == "root" { - certBase = "data/root-ca" - } else { - certBase = "data/issuer/ca-int" - } - - cmd := "openssl pkcs12 -export -inkey " + certBase + ".key -in " + certBase + ".pem -passout pass:" + r.Form.Get("export-pwd") + cmd := "openssl pkcs12 -export -inkey " + keyFile + " -in " + certFile + " -passout pass:" + r.Form.Get("export-pwd") _sendCmdOutput(w, r, cmd) } if r.Form.Get("type") == "zip" { w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", "attachment; filename=labca_"+basename+".zip") + w.Header().Set("Content-Disposition", "attachment; filename=labca_"+certname+".zip") - cmd := "zip -j -P " + r.Form.Get("export-pwd") + " - " - var certBase string - if r.Form.Get("root") == "true" { - certBase = "data/root-ca" - cmd = cmd + certBase + ".key " + certBase + ".pem " - } - if r.Form.Get("issuer") == "true" { - certBase = "data/issuer/ca-int" - cmd = cmd + certBase + ".key " + certBase + ".pem " - } + cmd := "zip -j -P " + r.Form.Get("export-pwd") + " - " + keyFile + " " + certFile _sendCmdOutput(w, r, cmd) } } +/* func _doCmdOutput(w http.ResponseWriter, r *http.Request, cmd string) string { parts := strings.Fields(cmd) for i := 0; i < len(parts); i++ { @@ -1022,6 +1006,7 @@ func _doCmdOutput(w http.ResponseWriter, r *http.Request, cmd string) string { return string(out) } +*/ func _encrypt(plaintext []byte) (string, error) { key := []byte(viper.GetString("keys.enc")) @@ -1126,7 +1111,7 @@ func generateCRLHandler(w http.ResponseWriter, r *http.Request, isRoot bool) { path := "data/" certBase := "root-ca" keyFileExists := true - if _, err := os.Stat(path + certBase + ".key"); os.IsNotExist(err) { + if _, err := os.Stat(path + certBase + ".key"); errors.Is(err, fs.ErrNotExist) { keyFileExists = false } if keyFileExists { @@ -1155,13 +1140,13 @@ func generateCRLHandler(w http.ResponseWriter, r *http.Request, isRoot bool) { } // Remove the Root Key if we want to keep it offline if viper.GetBool("keep_root_offline") { - if _, err := os.Stat(path + certBase + ".key"); !os.IsNotExist(err) { + if _, err := os.Stat(path + certBase + ".key"); !errors.Is(err, fs.ErrNotExist) { fmt.Println("Removing private Root key from the system...") if _, err := exeCmd("rm " + path + certBase + ".key"); err != nil { log.Printf("_certCreate: error deleting root key: %v", err) } } - if _, err := os.Stat(path + certBase + ".key.der"); !os.IsNotExist(err) { + if _, err := os.Stat(path + certBase + ".key.der"); !errors.Is(err, fs.ErrNotExist) { if _, err := exeCmd("rm " + path + certBase + ".key.der"); err != nil { log.Printf("_certCreate: error deleting root key (DER format): %v", err) } @@ -1205,6 +1190,63 @@ func uploadCRLHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(res) } +func updateLeaveIssuersHandler(w http.ResponseWriter, r *http.Request) { + res := struct { + Success bool + Error string + }{Success: true} + + if err := setUseForLeaves(r.Form.Get("rsa"), r.Form.Get("ecdsa")); err != nil { + res.Success = false + res.Error = err.Error() + } else { + defer func() { + if !_hostCommand(w, r, "boulder-restart") { + log.Printf("updateLeaveIssuersHandler: error restarting boulder: %v", err) + } + }() + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} + +func renewCertHandler(w http.ResponseWriter, r *http.Request) { + res := struct { + Success bool + Error string + }{Success: true} + + days, err := strconv.Atoi(r.Form.Get("days")) + if err != nil { + fmt.Printf("'%v' is not a number", r.Form.Get("days")) + errorHandler(w, r, err, http.StatusBadRequest) + return + } + + if err := renewCertificate(r.Form.Get("certname"), days, r.Form.Get("rootname"), r.Form.Get("root_key"), r.Form.Get("passphrase")); err != nil { + res.Success = false + res.Error = err.Error() + } + + ex, _ := os.Executable() + exePath := filepath.Dir(ex) + path, _ := filepath.Abs(exePath + "/..") + if _, err := exeCmd(path + "/apply"); err != nil { + fmt.Println(err) + res.Success = false + res.Error = "Could not apply: " + err.Error() + } + + if !_hostCommand(w, r, "boulder-restart") { + res.Success = false + res.Error = "Error restarting Boulder (ACME)" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(res) +} + func _managePostDispatch(w http.ResponseWriter, r *http.Request, action string) bool { if action == "backup-restore" || action == "backup-delete" || action == "backup-now" || action == "backup-upload" { _backupHandler(w, r) @@ -1266,6 +1308,16 @@ func _managePostDispatch(w http.ResponseWriter, r *http.Request, action string) return true } + if action == "update-leave-issuers" { + updateLeaveIssuersHandler(w, r) + return true + } + + if action == "renew-cert" { + renewCertHandler(w, r) + return true + } + if action == "svc-restart" { if _, err := exeCmd("./restart_control"); err != nil { log.Printf("_managePostDispatch: error restarting control container: %v", err) @@ -1319,6 +1371,8 @@ func _managePost(w http.ResponseWriter, r *http.Request) { "upload-root-crl", "gen-root-crl", "gen-issuer-crl", + "update-leave-issuers", + "renew-cert", } { if a == action { actionKnown = true @@ -1509,8 +1563,8 @@ func _manageGet(w http.ResponseWriter, r *http.Request) { backupFiles = backupFiles[:len(backupFiles)-1] manageData["BackupFiles"] = backupFiles - manageData["RootDetails"] = _doCmdOutput(w, r, "openssl x509 -noout -text -nameopt utf8 -in data/root-ca.pem") - manageData["IssuerDetails"] = _doCmdOutput(w, r, "openssl x509 -noout -text -nameopt utf8 -in data/issuer/ca-int.pem") + chains := getChains() + manageData["CertificateChains"] = chains if viper.Get("crl_interval") == nil || viper.GetString("crl_interval") == "" { manageData["CRLInterval"] = "24h" @@ -1575,6 +1629,54 @@ func manageHandler(w http.ResponseWriter, r *http.Request) { } } +/* +func manageNewRootHandler(w http.ResponseWriter, r *http.Request) { + if !viper.GetBool("config.complete") { + http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) + return + } + + // TODO: dynamically determine next filename (root-ca-2, root-ca-3, etc.) + + if !_certCreate(w, r, "root-ca-3", true) { + // Cleanup the cert (if it even exists) so we will retry on the next run + if _, err := os.Stat("data/root-ca-3.pem"); !errors.Is(err, fs.ErrNotExist) { + exeCmd("mv data/root-ca-3.pem data/root-ca-3.pem_TMP") + } + return + } + + // TODO: actually add the newly created key to the relevant config files (ca-a, ca-b, wfe2, possibly others) + + // TODO: reload boulder! + + http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/manage#certs", http.StatusSeeOther) +} + +func manageNewIssuerHandler(w http.ResponseWriter, r *http.Request) { + if !viper.GetBool("config.complete") { + http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/setup", http.StatusFound) + return + } + + // TODO: dynamically determine next filename (ca-int-2, ca-int-3, etc.) + + // Is revertroot at all relevant in this scenario? + + if !_certCreate(w, r, "ca-int-3", false) { + // Cleanup the cert (if it even exists) so we will retry on the next run + os.Remove("data/issuer/ca-int-3.pem") + return + } + + // TODO: actually add the newly created key to the relevant config files (ca-a, ca-b, wfe2, possibly others) + + // TODO: reload boulder! + + http.Redirect(w, r, r.Header.Get("X-Request-Base")+"/manage#certs", http.StatusSeeOther) +} +*/ + func logsHandler(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) logType := vars["type"] @@ -1749,9 +1851,11 @@ func _buildCI(r *http.Request, session *sessions.Session, isRoot bool) *Certific CreateType: "generate", CommonName: "Root CA", RequestBase: r.Header.Get("X-Request-Base"), + NumDays: 3652, // 10 years } if !isRoot { ci.CommonName = "CA" + ci.NumDays = 1826 // 5 years } ci.Initialize() @@ -1834,7 +1938,7 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot // Undo what setupHandler did when showing the public key... _, errPem := os.Stat("data/root-ca.pem") _, errTmp := os.Stat("data/root-ca.pem_TMP") - if os.IsNotExist(errPem) && !os.IsNotExist(errTmp) { + if errors.Is(errPem, fs.ErrNotExist) && !errors.Is(errTmp, fs.ErrNotExist) { exeCmd("mv data/root-ca.pem_TMP data/root-ca.pem") } @@ -1848,11 +1952,54 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot path = path + "issuer/" } - if _, err := os.Stat(path + certBase + ".pem"); os.IsNotExist(err) { + if _, err := os.Stat(path + certBase + ".pem"); errors.Is(err, fs.ErrNotExist) { session, _ := sessionStore.Get(r, "labca") if r.Method == "GET" { ci := _buildCI(r, session, isRoot) + if isRoot && (certBase == "root-ca" || certBase == "test-root") { + ci.IsFirst = true + } else if !isRoot && (certBase == "ca-int" || certBase == "test-ca") { + ci.IsFirst = true + } + + if len(r.URL.Query()["root"]) > 0 { + certFile := locateFile(r.URL.Query()["root"][0] + ".pem") + ci.RootEnddate, err = getCertFileNotAFter(certFile) + if err != nil { + fmt.Println(err.Error()) + errorHandler(w, r, err, http.StatusInternalServerError) + return false + } + + ci.RootSubject, err = getCertFileSubject(certFile) + if err != nil { + fmt.Println(err.Error()) + errorHandler(w, r, err, http.StatusInternalServerError) + return false + } + subjectMap := parseSubjectDn(ci.RootSubject) + if val, ok := subjectMap["C"]; ok { + ci.Country = val + } + if val, ok := subjectMap["O"]; ok { + ci.Organization = val + } + } else if !isRoot { + certFile := locateFile("root-ca.pem") + ci.RootEnddate, err = getCertFileNotAFter(certFile) + if err != nil { + fmt.Println(err.Error()) + errorHandler(w, r, err, http.StatusInternalServerError) + return false + } + ci.RootSubject, err = getCertFileSubject(certFile) + if err != nil { + fmt.Println(err.Error()) + errorHandler(w, r, err, http.StatusInternalServerError) + return false + } + } render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false @@ -1877,6 +2024,19 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot ci.OrgUnit = r.Form.Get("ou") ci.CommonName = r.Form.Get("cn") + ci.RootEnddate = r.Form.Get("root-enddate") + ci.RootSubject = r.Form.Get("root-subject") + if r.Form.Get("numdays") != "" { + ci.NumDays, err = strconv.Atoi(r.Form.Get("numdays")) + if err != nil { + if ci.IsRoot { + ci.NumDays = 3652 + } else { + ci.NumDays = 1826 + } + } + } + if ci.CreateType == "import" { file, handler, err := r.FormFile("import") if err != nil { @@ -1934,7 +2094,7 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot return false } defer csr.Close() - b, err := ioutil.ReadAll(csr) + b, _ := io.ReadAll(csr) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "CSR": string(b), "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false @@ -1973,7 +2133,7 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot return false } defer csr.Close() - b, err := ioutil.ReadAll(csr) + b, _ := io.ReadAll(csr) session.Values["csr"] = true if err = session.Save(r, w); err != nil { @@ -2002,13 +2162,13 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot } if viper.GetBool("keep_root_offline") { - if _, err := os.Stat(path + "../root-ca.key"); !os.IsNotExist(err) { + if _, err := os.Stat(path + "../root-ca.key"); !errors.Is(err, fs.ErrNotExist) { fmt.Println("Removing private Root key from the system...") if _, err := exeCmd("rm " + path + "../root-ca.key"); err != nil { log.Printf("_certCreate: error deleting root key: %v", err) } } - if _, err := os.Stat(path + "../root-ca.key.der"); !os.IsNotExist(err) { + if _, err := os.Stat(path + "../root-ca.key.der"); !errors.Is(err, fs.ErrNotExist) { if _, err := exeCmd("rm " + path + "../root-ca.key.der"); err != nil { log.Printf("_certCreate: error deleting root key (DER format): %v", err) } @@ -2040,7 +2200,7 @@ func _certCreate(w http.ResponseWriter, r *http.Request, certBase string, isRoot return false } defer key.Close() - b, err := ioutil.ReadAll(key) + b, _ := io.ReadAll(key) render(w, r, "cert:manage", map[string]interface{}{"CertificateInfo": ci, "RootKey": string(b), "Progress": _progress(certBase), "HelpText": _helptext(certBase)}) return false @@ -2160,9 +2320,9 @@ func _helptext(stage string) template.HTML { if stage == "register" { return template.HTML(fmt.Sprint("
You need to create an admin account for\n",
"managing this instance of LabCA. There can only be one admin account, but you can configure all\n",
- "its attributes once the initial setup has completed.
Instead, you can also\n",
+ "its attributes once the initial setup has completed.
Instead, you can also\n",
"restore from a backup file of a\n",
- "previous LabCA installation.
If you have a backup file from a previous LabCA installation and want to\n",
"restore this instance with the exact same configuration, use that backup file here.\n",
"
Otherwise you should follow the textarea {
- width: 600px;
+ width: 660px;
height: 550px;
}
@@ -197,3 +209,16 @@ pre.json {
.hide-overflow {
overflow: hidden;
}
+
+#modal-spinner {
+ z-index: 10000;
+}
+
+button.close {
+ margin-top: -40px;
+}
+
+.form-small-inline {
+ display: inline-block;
+ width: 5em !important;
+}
\ No newline at end of file
diff --git a/gui/templates/views/cert.tmpl b/gui/templates/views/cert.tmpl
index 6ab86c9..ec5a770 100644
--- a/gui/templates/views/cert.tmpl
+++ b/gui/templates/views/cert.tmpl
@@ -3,6 +3,9 @@
{{ if .IsRoot }}Root{{ else }}Issuer (2nd level){{ end }} Certificate
+ {{ if not .IsRoot }}
+ Root Subject: {{ .RootSubject }}
+ {{ end }}