diff --git a/.gitignore b/.gitignore index 41b89e1..4743c02 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,12 @@ lab-ca* *.pem # Ignore CA configuration and certificate definitions. *.hcl +# Ignore state files +*.json # Include example files !/examples/*.hcl # Exclude MacOS Finder metadata files -.DS_Store \ No newline at end of file +.DS_Store +# Exclude default certificate and private key files directories +/certs/ +/private/ diff --git a/ca.go b/ca.go index 8984bcf..6ccd2ac 100644 --- a/ca.go +++ b/ca.go @@ -12,6 +12,7 @@ import ( "net" "os" "path/filepath" + "regexp" "strings" "text/template" "time" @@ -40,6 +41,10 @@ type _CAConfig struct { Paths Paths `hcl:"paths,block"` } +func (c *_CAConfig) StateName() string { + return c.Label + "_state.json" +} + type Configuration struct { Current _CAConfig `hcl:"ca,block"` } @@ -70,10 +75,11 @@ var CAConfig *_CAConfig var CAKey *rsa.PrivateKey var CACert *x509.Certificate -// LoadCA loads the CA config and sets the global CAConfig variable -func LoadCA(path string) error { +// LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable +func LoadCAConfig() error { + fmt.Printf("Loading CA config from %s\n", configPath) parser := hclparse.NewParser() - file, diags := parser.ParseHCLFile(path) + file, diags := parser.ParseHCLFile(configPath) if diags.HasErrors() { return fmt.Errorf("failed to parse HCL: %s", diags.Error()) } @@ -91,9 +97,18 @@ func LoadCA(path string) error { if err := config.Current.Validate(); err != nil { return err } - CAConfig = &config.Current - err := error(nil) + return nil +} + +// LoadCA loads the CA config, certificate, key, and state +func LoadCA() error { + var err error + + err = LoadCAConfig() + if err != nil { + return err + } // Load CA key and certificate caCertPath := filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem") @@ -125,11 +140,7 @@ func LoadCA(path string) error { return fmt.Errorf("failed to parse CA private key: %v", err) } - // Derive caStatePath from caConfig label and config file path - caDir := filepath.Dir(path) - caLabel := config.Current.Label - caStatePath := filepath.Join(caDir, caLabel+"_state.json") - CAState, err = LoadCAState(caStatePath) + err = LoadCAState() if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to load CA state: %w", err) } @@ -178,58 +189,6 @@ func parseValidity(validity string) (time.Duration, error) { } } -func GenerateCA() ([]byte, []byte, error) { - // Use global CAConfig directly - keySize := CAConfig.KeySize - if keySize == 0 { - keySize = 4096 - } - priv, err := rsa.GenerateKey(rand.Reader, keySize) - if err != nil { - return nil, nil, err - } - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - if err != nil { - return nil, nil, fmt.Errorf("failed to generate serial number: %v", err) - } - validity, err := parseValidity(CAConfig.Validity) - if err != nil { - return nil, nil, err - } - now := time.Now() - tmpl := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Country: []string{CAConfig.Country}, - Organization: []string{CAConfig.Organization}, - OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit), - Locality: optionalSlice(CAConfig.Locality), - Province: optionalSlice(CAConfig.Province), - CommonName: CAConfig.Name, - }, - NotBefore: now, - NotAfter: now.Add(validity), - KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, - BasicConstraintsValid: true, - IsCA: true, - } - // Add email if present - if CAConfig.Email != "" { - tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{ - Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID - Value: CAConfig.Email, - }) - } - certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) - if err != nil { - return nil, nil, err - } - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) - return certPEM, keyPEM, nil -} - func SavePEM(filename string, data []byte, secure bool, overwrite bool) error { if !overwrite { if _, err := os.Stat(filename); err == nil { @@ -280,43 +239,123 @@ func (c *_CAConfig) Validate() error { return nil } -func InitCA(configPath string, overwrite bool) { - if err := LoadCA(configPath); err != nil { - fmt.Println("Error loading config:", err) - return +func InitCA(overwrite bool) error { + + var err error + + err = LoadCAConfig() + if err != nil { + return err } + // Create certificates directory with 0755, private keys with 0700 if CAConfig.Paths.Certificates != "" { if err := os.MkdirAll(CAConfig.Paths.Certificates, 0755); err != nil { fmt.Printf("Error creating certificates directory '%s': %v\n", CAConfig.Paths.Certificates, err) - return + return err } } if CAConfig.Paths.PrivateKeys != "" { if err := os.MkdirAll(CAConfig.Paths.PrivateKeys, 0700); err != nil { fmt.Printf("Error creating private keys directory '%s': %v\n", CAConfig.Paths.PrivateKeys, err) - return + return err } } - certPEM, keyPEM, err := GenerateCA() - if err != nil { - fmt.Println("Error generating CA:", err) - return + + // Initialize CAState empty state with serial starting from 1 + CAState = &_CAState{ + Serial: 1, // Start serial from 1 + CreatedAt: time.Now().UTC().Format(time.RFC3339), + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Certificates: []CertificateRecord{}, } + + keySize := CAConfig.KeySize + if keySize == 0 { + keySize = 4096 + } + priv, err := rsa.GenerateKey(rand.Reader, keySize) + if err != nil { + return err + } + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %v", err) + } + validity, err := parseValidity(CAConfig.Validity) + if err != nil { + return err + } + + now := time.Now() + // Store CA certificate creation time + CAState.CreatedAt = now.UTC().Format(time.RFC3339) + + tmpl := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Country: []string{CAConfig.Country}, + Organization: []string{CAConfig.Organization}, + OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit), + Locality: optionalSlice(CAConfig.Locality), + Province: optionalSlice(CAConfig.Province), + CommonName: CAConfig.Name, + }, + NotBefore: now, + NotAfter: now.Add(validity), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + // Add email if present + if CAConfig.Email != "" { + tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{ + Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID + Value: CAConfig.Email, + }) + } + certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) + if err := SavePEM(filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil { fmt.Println("Error saving CA certificate:", err) - return + return err } if err := SavePEM(filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true, overwrite); err != nil { fmt.Println("Error saving CA key:", err) - return + return err + } + + // set last updated time in the CAState + CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + // Save the state + + err = SaveCAState() + if err != nil { + fmt.Println("Error saving CA state:", err) + return err } fmt.Println("CA certificate and key generated.") + + return nil } // Helper: issue a single certificate and key, save to files, return error if any func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) error { - // Use global CAConfig directly + // Validate Name + if !isValidName(def.Name) { + return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore") + } + // Initialize Subject if not specified + if def.Subject == "" { + def.Subject = def.Name + } + // Add default dns SAN for server/server-only if none specified if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 { def.SAN = append(def.SAN, "dns:"+def.Subject) @@ -349,11 +388,14 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) subjectPKIX = pkix.Name{CommonName: def.Subject} } + dateIssued := time.Now() + expires := dateIssued.Add(validityDur) + certTmpl := x509.Certificate{ SerialNumber: serialNumber, Subject: subjectPKIX, - NotBefore: time.Now(), - NotAfter: time.Now().Add(validityDur), + NotBefore: dateIssued, + NotAfter: expires, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, } @@ -397,8 +439,8 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool) if basename == "" { basename = def.Subject } - certFile := filepath.Join(CAConfig.Paths.Certificates, basename+"."+def.Type+".crt.pem") - keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+"."+def.Type+".key.pem") + certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem") + keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+".key.pem") if err := SavePEM(certFile, certPEM, false, overwrite); err != nil { return fmt.Errorf("error saving certificate: %v", err) } @@ -421,15 +463,20 @@ Certificate: def.SAN, ) } + CAState.UpdateCAStateAfterIssue( + CAConfig.SerialType, + basename, + serialNumber, + validityDur, + ) return nil } -func IssueCertificate(configPath, subject, certType, validity string, san []string, name, fromFile string, overwrite, dryRun, verbose bool) { +func IssueCertificate(configPath, subject, certType, validity string, san []string, name, fromFile string, overwrite, dryRun, verbose bool) error { if fromFile != "" { certDefs, defaults, err := LoadCertificatesFile(fromFile) if err != nil { - fmt.Printf("Error loading certificates file: %v\n", err) - return + return fmt.Errorf("Error loading certificates file: %v", err) } successes := 0 errors := 0 @@ -464,34 +511,29 @@ func IssueCertificate(configPath, subject, certType, validity string, san []stri } } fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors) - // Save CA state after batch issuance - caDir := filepath.Dir(configPath) - caLabel := CAConfig.Label - caStatePath := filepath.Join(caDir, caLabel+"_state.json") - if err := SaveCAState(caStatePath, CAState); err != nil { + if err := SaveCAState(); err != nil { fmt.Printf("Error saving CA state: %v\n", err) } - return + if errors > 0 { + return fmt.Errorf("%d certificate(s) failed to issue", errors) + } + return nil } // Single mode finalDef := renderCertificateDefTemplates(CertificateDefinition{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil) if dryRun { fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", finalDef.Type, finalDef.Subject) - return + return nil } err := issueSingleCertificate(finalDef, overwrite, verbose) if err != nil { - fmt.Printf("Error: %v\n", err) - return + return err } fmt.Printf("%s certificate and key for '%s' generated.\n", finalDef.Type, finalDef.Subject) - // Save CA state after single issuance - caDir := filepath.Dir(configPath) - caLabel := CAConfig.Label - caStatePath := filepath.Join(caDir, caLabel+"_state.json") - if err := SaveCAState(caStatePath, CAState); err != nil { + if err := SaveCAState(); err != nil { fmt.Printf("Error saving CA state: %v\n", err) } + return nil } // Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature) @@ -616,3 +658,9 @@ func optionalSlice(s string) []string { } return []string{s} } + +// Helper: validate certificate name using regex +func isValidName(name string) bool { + matched, _ := regexp.MatchString(`^[A-Za-z0-9_-]+$`, name) + return matched +} diff --git a/certdb.go b/certdb.go index e4b2e01..ec11fa3 100644 --- a/certdb.go +++ b/certdb.go @@ -2,9 +2,15 @@ package main import ( + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "encoding/json" + "encoding/pem" "fmt" + "math/big" "os" + "path/filepath" "time" ) @@ -14,54 +20,55 @@ type _CAState struct { CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Serial int `json:"serial,omitempty"` + CRLNumber int `json:"crlNumber"` Certificates []CertificateRecord `json:"certificates"` } type CertificateRecord struct { - Name string `json:"name"` - Issued string `json:"issued"` - Expires string `json:"expires"` - Serial string `json:"serial"` - Valid bool `json:"valid"` + Name string `json:"name"` + Issued string `json:"issued"` + Expires string `json:"expires"` + Serial string `json:"serial"` + RevokedAt string `json:"revokedAt,omitempty"` + RevokeReason int `json:"revokeReason,omitempty"` +} + +func caStatePath() string { + return filepath.Join(filepath.Dir(configPath), CAConfig.StateName()) } // LoadCAState loads the CA state from a JSON file -func LoadCAState(filename string) (*_CAState, error) { - f, err := os.Open(filename) +func LoadCAState() error { + path := caStatePath() + fmt.Printf("Loading CA state from %s\n", path) + f, err := os.Open(path) if err != nil { - return nil, err + return err } defer f.Close() - var state _CAState - if err := json.NewDecoder(f).Decode(&state); err != nil { - return nil, err + CAState = &_CAState{} + if err := json.NewDecoder(f).Decode(CAState); err != nil { + return err } - return &state, nil + return nil } // SaveCAState saves the CA state to a JSON file -func SaveCAState(filename string, state *_CAState) error { - state.UpdatedAt = time.Now().UTC().Format(time.RFC3339) - f, err := os.Create(filename) +func SaveCAState() error { + CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + f, err := os.Create(caStatePath()) if err != nil { return err } defer f.Close() enc := json.NewEncoder(f) enc.SetIndent("", " ") - return enc.Encode(state) + return enc.Encode(CAState) } // UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate -func UpdateCAStateAfterIssue(jsonFile, serialType, basename string, serialNumber any, validity time.Duration) error { - var err error - if CAState == nil { - CAState, err = LoadCAState(jsonFile) - if err != nil { - CAState = nil - } - } - if CAState == nil { +func (s *_CAState) UpdateCAStateAfterIssue(serialType, basename string, serialNumber any, validity time.Duration) error { + if s == nil { fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n") os.Exit(1) } @@ -77,13 +84,12 @@ func UpdateCAStateAfterIssue(jsonFile, serialType, basename string, serialNumber default: serialStr = fmt.Sprintf("%v", serialNumber) } - AddCertificate(basename, issued, expires, serialStr, true) + s.AddCertificate(basename, issued, expires, serialStr) return nil } -// AddCertificate appends a new CertificateRecord to the CAState -func AddCertificate(name, issued, expires, serial string, valid bool) { - if CAState == nil { +func (s *_CAState) AddCertificate(name, issued, expires, serial string) { + if s == nil { fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n") os.Exit(1) } @@ -92,9 +98,94 @@ func AddCertificate(name, issued, expires, serial string, valid bool) { Issued: issued, Expires: expires, Serial: serial, - Valid: valid, } - CAState.Certificates = append(CAState.Certificates, rec) + s.Certificates = append(s.Certificates, rec) } -// No CAConfig references to update in this file +// RevokeCertificate revokes a certificate by serial number and reason code, updates state, and saves to disk +func (s *_CAState) RevokeCertificate(serial string, reason int) error { + if s == nil { + fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n") + os.Exit(1) + } + revoked := false + revokedAt := time.Now().UTC().Format(time.RFC3339) + for i, rec := range s.Certificates { + if rec.Serial == serial && rec.RevokedAt == "" { + s.Certificates[i].RevokedAt = revokedAt + s.Certificates[i].RevokeReason = reason + revoked = true + } + } + if !revoked { + return fmt.Errorf("certificate with serial %s not found or already revoked", serial) + } + s.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + if err := SaveCAState(); err != nil { + return fmt.Errorf("failed to save CA state after revocation: %v", err) + } + return nil +} + +// GenerateCRL generates a CRL file from revoked certificates and writes it to the given path +// validityDays defines the number of days for which the CRL is valid (NextUpdate - ThisUpdate) +func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error { + if s == nil { + return fmt.Errorf("CAState is nil in GenerateCRL") + } + if CACert == nil || CAKey == nil { + return fmt.Errorf("CA certificate or key not loaded") + } + var revokedCerts []pkix.RevokedCertificate + for _, rec := range s.Certificates { + if rec.RevokedAt != "" { + serial := new(big.Int) + serial.SetString(rec.Serial, 16) // Parse serial as hex + revokedTime, err := time.Parse(time.RFC3339, rec.RevokedAt) + if err != nil { + return fmt.Errorf("invalid revocation time for serial %s: %v", rec.Serial, err) + } + reasonCode := rec.RevokeReason + // RFC 5280: Reason code must be encoded as ASN.1 ENUMERATED, not a raw byte + // Use ASN.1 encoding for ENUMERATED + asn1Reason, err := asn1.Marshal(asn1.Enumerated(reasonCode)) + if err != nil { + return fmt.Errorf("failed to ASN.1 encode reason code: %v", err) + } + revokedCerts = append(revokedCerts, pkix.RevokedCertificate{ + SerialNumber: serial, + RevocationTime: revokedTime, + Extensions: []pkix.Extension{{Id: []int{2, 5, 29, 21}, Value: asn1Reason}}, + }) + } + } + now := time.Now().UTC() + nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds + template := &x509.RevocationList{ + SignatureAlgorithm: CACert.SignatureAlgorithm, + RevokedCertificates: revokedCerts, + Number: big.NewInt(int64(s.CRLNumber + 1)), + ThisUpdate: now, + NextUpdate: nextUpdate, + Issuer: CACert.Subject, + } + crlBytes, err := x509.CreateRevocationList(nil, template, CACert, CAKey) + if err != nil { + return fmt.Errorf("failed to create CRL: %v", err) + } + f, err := os.Create(crlPath) + if err != nil { + return fmt.Errorf("failed to create CRL file: %v", err) + } + defer f.Close() + if err := pem.Encode(f, &pem.Block{Type: "X509 CRL", Bytes: crlBytes}); err != nil { + return fmt.Errorf("failed to write CRL PEM: %v", err) + } + // Update CRL number and save state + s.CRLNumber++ + s.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + if err := SaveCAState(); err != nil { + return fmt.Errorf("failed to update CA state after CRL generation: %v", err) + } + return nil +} diff --git a/examples/example-certificates.hcl b/examples/example-certificates.hcl index f0696ce..7a3bddc 100644 --- a/examples/example-certificates.hcl +++ b/examples/example-certificates.hcl @@ -18,3 +18,5 @@ certificate "loki" { # from default: validity = "1y" san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ] } + +certificate "alloy" {} diff --git a/main.go b/main.go index f990c59..40e028d 100644 --- a/main.go +++ b/main.go @@ -8,9 +8,10 @@ import ( ) var Version = "dev" +var configPath string func main() { - var configPath string + var overwrite bool var subject string var certType string @@ -20,6 +21,11 @@ func main() { var fromFile string var dryRun bool var verbose bool + var crlFile string + var crlValidityDays int + var revokeName string + var revokeSerial string + var revokeReasonStr string var rootCmd = &cobra.Command{ Use: "lab-ca", @@ -34,11 +40,7 @@ func main() { Use: "initca", Short: "Generate a new CA certificate and key", Run: func(cmd *cobra.Command, args []string) { - if err := LoadCA(configPath); err != nil { - fmt.Printf("Error loading CA config: %v\n", err) - os.Exit(1) - } - InitCA(configPath, overwrite) + InitCA(overwrite) }, } @@ -49,11 +51,23 @@ func main() { Use: "issue", Short: "Issue a new certificate (client, server, server-only, code-signing, email)", Run: func(cmd *cobra.Command, args []string) { - if err := LoadCA(configPath); err != nil { - fmt.Printf("Error loading CA config: %v\n", err) + if err := LoadCA(); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } + if err := IssueCertificate(configPath, + subject, + certType, + validity, + san, + name, + fromFile, + overwrite, + dryRun, + verbose); err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } - IssueCertificate(configPath, subject, certType, validity, san, name, fromFile, overwrite, dryRun, verbose) }, } @@ -83,9 +97,93 @@ func main() { fmt.Printf("lab-ca version: %s\n", Version) }, } + + var crlCmd = &cobra.Command{ + Use: "crl", + Short: "Generate a Certificate Revocation List (CRL)", + Run: func(cmd *cobra.Command, args []string) { + if err := LoadCA(); err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + if crlValidityDays <= 0 { + crlValidityDays = 30 // default to 30 days + } + err := CAState.GenerateCRL(crlFile, crlValidityDays) + if err != nil { + fmt.Printf("ERROR generating CRL: %v\n", err) + os.Exit(1) + } + fmt.Printf("CRL written to %s (valid for %d days)\n", crlFile, crlValidityDays) + }, + } + crlCmd.Flags().StringVar(&crlFile, "crl-file", "crl.pem", "Output path for CRL file (default: crl.pem)") + crlCmd.Flags().IntVar(&crlValidityDays, "validity-days", 30, "CRL validity in days (default: 30)") + + var revokeCmd = &cobra.Command{ + Use: "revoke", + Short: "Revoke a certificate by name or serial number", + Run: func(cmd *cobra.Command, args []string) { + if err := LoadCA(); err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + if (revokeName == "" && revokeSerial == "") || (revokeName != "" && revokeSerial != "") { + fmt.Println("ERROR: You must specify either --name or --serial (but not both)") + os.Exit(1) + } + serial := "" + if revokeName != "" { + found := false + for _, rec := range CAState.Certificates { + if rec.Name == revokeName { + serial = rec.Serial + found = true + break + } + } + if !found { + fmt.Printf("ERROR: Certificate with name '%s' not found\n", revokeName) + os.Exit(1) + } + } else { + serial = revokeSerial + } + reasonMap := map[string]int{ + "unspecified": 0, + "keyCompromise": 1, + "caCompromise": 2, + "affiliationChanged": 3, + "superseded": 4, + "cessationOfOperation": 5, + "certificateHold": 6, + "removeFromCRL": 8, + } + reasonCode, ok := reasonMap[revokeReasonStr] + if !ok { + fmt.Printf("ERROR: Unknown revocation reason '%s'. Valid reasons: ", revokeReasonStr) + for k := range reasonMap { + fmt.Printf("%s ", k) + } + fmt.Println() + os.Exit(1) + } + if err := CAState.RevokeCertificate(serial, reasonCode); err != nil { + fmt.Printf("ERROR: %v\n", err) + os.Exit(1) + } + fmt.Printf("Certificate with serial %s revoked (reason: %s, code %d)\n", serial, revokeReasonStr, reasonCode) + }, + } + revokeCmd.Flags().StringVar(&revokeName, "name", "", "Certificate name to revoke (mutually exclusive with --serial)") + revokeCmd.Flags().StringVar(&revokeSerial, "serial", "", "Certificate serial number to revoke (mutually exclusive with --name)") + revokeCmd.Flags().StringVar(&revokeReasonStr, "reason", "cessationOfOperation", "Revocation reason (unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, removeFromCRL)") + rootCmd.AddCommand(initCmd) rootCmd.AddCommand(issueCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(crlCmd) + rootCmd.AddCommand(revokeCmd) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -102,6 +200,8 @@ func printMainHelp() { fmt.Println(" initca Generate a new CA certificate and key") fmt.Println(" issue Issue a new client/server certificate") fmt.Println(" version Show version information") + fmt.Println(" crl Generate a Certificate Revocation List (CRL)") + fmt.Println(" revoke Revoke a certificate by name or serial number") fmt.Println() fmt.Println("Use 'lab-ca --help' for more information about a command.") } diff --git a/run-test.sh b/run-test.sh new file mode 100755 index 0000000..7c331e2 --- /dev/null +++ b/run-test.sh @@ -0,0 +1,41 @@ +#!/bin/bash +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +go build +rm -rf certs private *.json crl*.pem + +echo -e "\n${GREEN}Initializing CA...${NC}" +./lab-ca initca || exit 1 + +echo -e "\n${GREEN}Issuing single certificate with incorrect argument..${NC}" +./lab-ca issue --name "blackpanther2.koszewscy.waw.pl" +if [ $? -ne 0 ]; then + echo -e "${GREEN}Failed to issue certificate.${NC} - that's fine it was intended." +else + echo -e "${GREEN}FATAL: The command should fail, but it didn't!${NC}" + exit 1 +fi + +echo -e "\n${GREEN}Issuing single certificate..${NC}" +./lab-ca issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1 + +echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}" +./lab-ca issue --from-file examples/example-certificates.hcl --verbose || exit 1 + +echo -e "\n${GREEN}Revoking a certificate by name...${NC}" +./lab-ca revoke --name "loki" || exit 1 + +echo -e "\n${GREEN}Generating CRL...${NC}" +./lab-ca crl --validity-days 7 --crl-file crl-1.pem || exit 1 +openssl crl -noout -text -in crl-1.pem + +echo -e "\n${GREEN}Revoking a second certificate by name...${NC}" +./lab-ca revoke --name "alloy" || exit 1 + +echo -e "\n${GREEN}Generating a second CRL...${NC}" +./lab-ca crl --validity-days 7 --crl-file crl-2.pem || exit 1 +openssl crl -noout -text -in crl-2.pem + +echo -e "\n${GREEN}Dumping CA state...${NC}" +jq '.' example_ca_state.json