16 Commits

Author SHA1 Message Date
6682be6eb1 Added list command and ability to combine certificate usages. 2025-07-28 18:58:37 +02:00
9b7b995e97 Added function that can look for a certificate in the database. 2025-07-28 18:57:57 +02:00
b387a016be Added state file location defintion to the CA configuration. Added more certificate properties to certificate database. 2025-07-28 17:41:41 +02:00
e4469fde96 More renames. 2025-07-28 15:53:17 +02:00
bea0285007 Refactoring 2025-07-28 15:36:45 +02:00
a8308e0f4f Moved global flag variables to the global scope. Refactored some functions interfaces and removed passing global flags. 2025-07-28 13:48:38 +02:00
911d33deb2 A fix to module name. 2025-07-28 11:54:48 +02:00
8b103f4c0f Updated documentation. 2025-07-28 10:44:10 +02:00
0e86c49965 Mostly implemented issue and provision commands. Restructured AI generateed code. 2025-07-28 10:20:52 +02:00
9696f95043 Few manual optimizations and corrections of AI generated code. 2025-07-28 06:40:17 +02:00
6c5842826e Added certificate revocation and CRL generation code. 2025-07-28 01:34:21 +02:00
bd9547ff70 Simplified template rendering. 2025-07-27 21:46:27 +02:00
dba4ced05f Moved loading CA's private key and certificate to global configuration loading process. 2025-07-27 21:16:18 +02:00
e2039550e0 CAConfig global variable and refactoring. 2025-07-27 20:45:05 +02:00
10ec83273d Refactored code to one IssueCertificate function. 2025-07-27 20:00:43 +02:00
0c32da1e84 Partial implementation of CA database. 2025-07-27 19:48:38 +02:00
10 changed files with 988 additions and 518 deletions

7
.gitignore vendored
View File

@@ -6,7 +6,12 @@ lab-ca*
*.pem *.pem
# Ignore CA configuration and certificate definitions. # Ignore CA configuration and certificate definitions.
*.hcl *.hcl
# Ignore state files
*.json
# Include example files # Include example files
!/examples/*.hcl !/examples/*.hcl
# Exclude MacOS Finder metadata files # Exclude MacOS Finder metadata files
.DS_Store .DS_Store
# Exclude default certificate and private key files directories
/certs/
/private/

View File

@@ -23,10 +23,14 @@ The tool is designed to be used from the command line. It has a simple command s
lab-ca <command> [options] lab-ca <command> [options]
``` ```
There are two commands available: The main commands available are:
- `initca` - initialize a new CA - this command creates a new CA and a self-signed CA certificate. - `initca` — Initialize a new CA and create a self-signed CA certificate.
- `issue` - issue a new certificate - this command creates a new certificate signed by the CA. - `issue` — Issue a new certificate signed by the CA.
- `provision` — Provision multiple certificates from a batch file (HCL) in one go.
- `revoke` — Revoke a certificate by name or serial number.
- `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates.
- `version` — Show version information for the tool.
Run the command with `-h` or `--help` or without any arguments to see the usage information. Each command has its own set of options, arguments, and a help message. Run the command with `-h` or `--help` or without any arguments to see the usage information. Each command has its own set of options, arguments, and a help message.
@@ -70,35 +74,47 @@ The `paths` block defines where the command will store the generated certificate
> **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment. > **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment.
## Certificate Issuance ## Certificate Issuance and Provisioning
To issue a new certificate, you can use the `issue` command and specify the certificate definition on the command line, or use batch mode and provide a file with certificate definitions. To issue a new certificate, you can use the `issue` command and specify the certificate definition on the command line, or use the `provision` command to provide a file with multiple certificate definitions for batch processing.
The definition file also uses HCL syntax. Here is an example of a certificate definition: The definition file also uses HCL syntax. Here is an example of a certificate definition file:
```hcl ```hcl
defaults { defaults {
subject = "{{ .Name }}.example.com" subject = "{{ .Name }}.example.org"
type = "server" type = "server"
validity = "1y" validity = "1y"
san = ["DNS:{{ .Name }}.example.com"] san = ["DNS:{{ .Name }}.example.org"]
} }
certificate "grafana" { variables = {
# from default: subject = "{{ .Name }}.example.com" # result: grafana.example.com Domain = "example.net"
# from default: type = "server" Country = "EX"
# from default: validity = "1y"
# from default: san = ["DNS:{{ .Name }}.example.com"] # result: [ "DNS:grafana.example.com" ]
} }
certificate "loki" { certificate "service1" {
subject = "{{ .Name }}.example.net" # result: loki.example.net # from default: subject = "{{ .Name }}.example.org"
# from default: type = "server" # from default: type = "server"
# from default: validity = "1y" # from default: validity = "1y"
san = ["DNS:{{ .Name }}.example.net"] # result: [ "DNS:loki.example.net" ] # from default: san = ["DNS:{{ .Name }}.example.org"]
}
certificate "service2" {
subject = "{{ .Name }}.example.net"
# from default: type = "server"
# from default: validity = "1y"
san = ["DNS:{{ .Name }}.example.net"]
}
certificate "service3" {}
certificate "service4" {
subject = "{{ .Name }}.{{ .Domain }}"
san = ["DNS:{{ .Name }}.{{ .Domain }}"]
} }
``` ```
Values specified in the `defaults` block will be used for all certificates unless overridden in individual certificate definitions. Go-style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name. Values specified in the `defaults` block will be used for all certificates unless overridden in individual certificate definitions. Go-style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name, and variables from the `variables` map can be used in templates as well.
You can use DNS or IP SANs for server certificates (`server` and `server-only`), and email SANs for email certificates (`email`). The command will check if the SAN is valid based on the type of certificate. You can use DNS or IP SANs for server certificates (`server` and `server-only`), and email SANs for email certificates (`email`). The command will check if the SAN is valid based on the type of certificate.

859
ca.go

File diff suppressed because it is too large Load Diff

221
certdb.go Normal file
View File

@@ -0,0 +1,221 @@
// A certificate database management functions
package main
import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"time"
)
// CAState represents the persisted CA state in JSON
type CAState struct {
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Serial int `json:"serial,omitempty"`
CRLNumber int `json:"crlNumber"`
Certificates []CertificateRecord `json:"certificates"`
}
// CertificateRecord represents a single certificate record in the CA state
type CertificateRecord struct {
Name string `json:"name"`
Subject string `json:"subject"`
Type string `json:"type"`
Issued string `json:"issued"`
Expires string `json:"expires"`
Serial string `json:"serial"`
RevokedAt string `json:"revokedAt,omitempty"`
RevokeReason int `json:"revokeReason,omitempty"`
}
// Look for a certifcate by its name
func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
if cert.RevokedAt != "" && !all {
continue
}
if cert.Name == name {
return &cert
}
}
return nil
}
// Look for a certificate by its serial
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
if cert.RevokedAt != "" && !all {
continue
}
if cert.Serial == serial {
return &cert
}
}
return nil
}
// func caStatePath() string {
// return filepath.Join(filepath.Dir(caConfigPath), caConfig.GetStateFileName())
// }
// LoadCAState loads the CA state from a JSON file
func LoadCAState() error {
fmt.Printf("Loading CA state from %s\n", caStatePath)
f, err := os.Open(caStatePath)
if err != nil {
return err
}
defer f.Close()
caState = &CAState{}
if err := json.NewDecoder(f).Decode(caState); err != nil {
return err
}
return nil
}
// SaveCAState saves the CA state to a JSON file
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(caState)
}
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject string, certType 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)
}
issued := time.Now().UTC().Format(time.RFC3339)
expires := time.Now().Add(validity).UTC().Format(time.RFC3339)
serialStr := ""
switch serialType {
case "sequential":
serialStr = fmt.Sprintf("%d", caState.Serial)
caState.Serial++
case "random":
serialStr = fmt.Sprintf("%x", serialNumber)
default:
serialStr = fmt.Sprintf("%v", serialNumber)
}
s.AddCertificate(name, subject, certType, issued, expires, serialStr)
return nil
}
func (s *CAState) AddCertificate(name, subject, certType, 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)
}
rec := CertificateRecord{
Name: name,
Subject: subject,
Type: certType,
Issued: issued,
Expires: expires,
Serial: serial,
}
s.Certificates = append(s.Certificates, rec)
}
// 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
}

View File

@@ -1,13 +1,14 @@
ca "example_ca" { ca "example_ca" {
name = "Example CA" name = "Example CA"
country = "PL" country = "PL"
organization = "ACME Corp" organization = "ACME Corp"
serial_type = "random" serial_type = "random"
key_size = 4096 key_size = 4096
validity = "10y" validity = "10y"
paths { paths {
certificates = "certs" certificates = "certs"
private_keys = "private" private_keys = "private"
state_file = "ca_state.json"
} }
} }

View File

@@ -5,6 +5,11 @@ defaults {
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"] san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
} }
variables = {
Domain = "koszewscy.email"
Country = "PL"
}
certificate "grafana" { certificate "grafana" {
# from default: subject = "{{ .Name }}.koszewscy.waw.pl" # result: grafana.koszewscy.waw.pl # from default: subject = "{{ .Name }}.koszewscy.waw.pl" # result: grafana.koszewscy.waw.pl
# from default: type = "server" # from default: type = "server"
@@ -18,3 +23,10 @@ certificate "loki" {
# from default: validity = "1y" # from default: validity = "1y"
san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ] san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ]
} }
certificate "alloy" {}
certificate "prometheus" {
subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ]
}

View File

@@ -0,0 +1,14 @@
{
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"serial": 1,
"certificates": [
{
"name": "",
"issued": "",
"expires": "",
"serial": "",
"valid": true
}
]
}

2
go.mod
View File

@@ -1,4 +1,4 @@
module koszewscy.waw.pl/slawek/lab-ca module gitea.koszewscy.waw.pl/slawek/lab-ca
go 1.24.5 go 1.24.5

286
main.go
View File

@@ -9,17 +9,34 @@ import (
var Version = "dev" var Version = "dev"
// Global flags available to all commands
var overwrite bool
var dryRun bool
var verbose bool
func main() { func main() {
var configPath string
var overwrite bool // list command flags
var listRevoked bool
// issue command flags
var name string
var subject string var subject string
var certType string var certType string
var validity string var validity string
var san []string var san []string
var name string
var fromFile string // provision command flags
var dryRun bool var provisionFile string
var verbose bool
// crl command flags
var crlFile string
var crlValidityDays int
// revoke command flags
var revokeName string
var revokeSerial string
var revokeReasonStr string
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "lab-ca", Use: "lab-ca",
@@ -30,115 +47,180 @@ func main() {
}, },
} }
var initcaCmd = &cobra.Command{ // Define persistent flags (global for all commands)
rootCmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
// lab-ca initca command
var initCmd = &cobra.Command{
Use: "initca", Use: "initca",
Short: "Generate a new CA certificate and key", Short: "Generate a new CA certificate and key",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
InitCA(configPath, overwrite) InitCA()
}, },
} }
rootCmd.AddCommand(initCmd)
initcaCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file") // lab-ca list command
initcaCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files") var listCmd = &cobra.Command{
Use: "list",
Short: "List issued certificates",
Run: func(cmd *cobra.Command, args []string) {
err := LoadCA()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
for _, certDef := range caState.Certificates {
if certDef.RevokedAt != "" {
continue
}
fmt.Printf("Certificate %s\n", certDef.Name)
fmt.Printf("\tSubject: %s\n\tType: %s\n\tIssued at: %s\n",
certDef.Subject, certDef.Type, certDef.Issued)
}
},
}
listCmd.Flags().BoolVar(&listRevoked, "revoked", false, "List all certificates, including revoked ones")
rootCmd.AddCommand(listCmd)
// lab-ca issue command
var issueCmd = &cobra.Command{ var issueCmd = &cobra.Command{
Use: "issue", Use: "issue",
Short: "Issue a new certificate (client, server, server-only, code-signing, email)", Short: "Issue a new certificate (client, server, server-only, code-signing, email)",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if fromFile != "" { err := IssueCertificate(CertificateDefinition{
certDefs, defaults, err := LoadCertificatesFile(fromFile) Name: name,
if err != nil { Subject: subject,
fmt.Printf("Error loading certificates file: %v\n", err) Type: certType,
return Validity: validity,
} SAN: san,
successes := 0 }, overwrite, dryRun, verbose)
errors := 0
for i, def := range certDefs {
if defaults != nil {
if def.Type == "" {
def.Type = defaults.Type
}
if def.Validity == "" {
def.Validity = defaults.Validity
}
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
def.SAN = defaults.SAN
}
}
finalDef := renderCertificateDefTemplates(def, defaults)
fmt.Printf("[%d/%d] Issuing %s... ", i+1, len(certDefs), finalDef.Name) if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
if dryRun { os.Exit(1)
fmt.Printf("(dry run)\n")
}
if verbose {
fmt.Printf("\n Name: %s\n", finalDef.Name)
fmt.Printf(" Subject: %s\n", finalDef.Subject)
fmt.Printf(" Type: %s\n", finalDef.Type)
fmt.Printf(" Validity: %s\n", finalDef.Validity)
fmt.Printf(" SAN: %v\n\n", finalDef.SAN)
}
basename := finalDef.Name + "." + finalDef.Type
if dryRun {
successes++
continue
}
err := IssueCertificateWithBasename(configPath, basename, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite, dryRun)
if err != nil {
fmt.Printf("ERROR: %v\n", err)
errors++
} else {
if !verbose {
fmt.Printf("done\n")
}
successes++
}
}
fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors)
return
} }
// Simple mode
subjectName := subject
if subjectName == "" {
subjectName = name
}
finalDef := renderCertificateDefTemplates(CertificateDef{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil)
if verbose {
fmt.Printf("\nCertificate:\n")
fmt.Printf(" Name: %s\n", finalDef.Name)
fmt.Printf(" Subject: %s\n", finalDef.Subject)
fmt.Printf(" Type: %s\n", finalDef.Type)
fmt.Printf(" Validity: %s\n", finalDef.Validity)
fmt.Printf(" SAN: %v\n", finalDef.SAN)
}
IssueCertificate(configPath, finalDef.Name, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite)
}, },
} }
issueCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file")
issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)")
issueCmd.Flags().StringVar(&certType, "type", "server", "Certificate type: client, server, server-only, code-signing, email")
issueCmd.Flags().StringArrayVar(&san, "san", nil,
"Subject Alternative Name (SAN). Use multiple times for multiple values.\n"+
"Format: dns:example.com, ip:1.2.3.4, email:user@example.com")
issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.")
issueCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
issueCmd.Flags().StringVar(&fromFile, "from-file", "", "Path to HCL file with multiple certificate definitions (batch mode)")
issueCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
issueCmd.Flags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate")
// Only require --name in simple mode
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)") issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)")
issueCmd.PreRun = func(cmd *cobra.Command, args []string) { issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)")
if fromFile == "" { issueCmd.Flags().StringVar(&certType, "type", "server",
cmd.MarkFlagRequired("name") "Certificate type: client, server, code-signing, email.\nCombine by specifying more than one separated by comma.")
} issueCmd.Flags().StringArrayVar(&san, "san", nil,
"Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com")
issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.")
issueCmd.MarkFlagRequired("name")
rootCmd.AddCommand(issueCmd)
// lab-ca provision command
var provisionCmd = &cobra.Command{
Use: "provision",
Short: "Provision certificates from a batch file (HCL)",
Run: func(cmd *cobra.Command, args []string) {
err := ProvisionCertificates(provisionFile, overwrite, false, verbose)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
},
} }
provisionCmd.Flags().StringVar(&provisionFile, "file", "", "Path to HCL file with certificate definitions (required)")
provisionCmd.MarkFlagRequired("file")
rootCmd.AddCommand(provisionCmd)
// lab-ca revoke command
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(revokeCmd)
// lab-ca crl command
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)")
rootCmd.AddCommand(crlCmd)
// lab-ca version command
var versionCmd = &cobra.Command{ var versionCmd = &cobra.Command{
Use: "version", Use: "version",
Short: "Show version information", Short: "Show version information",
@@ -146,8 +228,6 @@ func main() {
fmt.Printf("lab-ca version: %s\n", Version) fmt.Printf("lab-ca version: %s\n", Version)
}, },
} }
rootCmd.AddCommand(initcaCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(versionCmd)
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
@@ -156,15 +236,19 @@ func main() {
} }
func printMainHelp() { func printMainHelp() {
fmt.Println("lab-ca - Certificate Authority Utility") fmt.Printf("lab-ca - Certificate Authority Utility\n")
fmt.Printf("Version: %s\n", Version)
fmt.Println() fmt.Println()
fmt.Println("Usage:") fmt.Println("Usage:")
fmt.Println(" lab-ca <command> [options]") fmt.Println(" lab-ca <command> [options]")
fmt.Println() fmt.Println()
fmt.Println("Available commands:") fmt.Println("Available commands:")
fmt.Println(" initca Generate a new CA certificate and key") fmt.Println(" initca Generate a new CA certificate and key")
fmt.Println(" issue Issue a new client/server certificate") fmt.Println(" issue Issue a new certificate")
fmt.Println(" version Show version information") fmt.Println(" provision Provision certificates from a batch file (HCL)")
fmt.Println(" revoke Revoke a certificate by name or serial number")
fmt.Println(" crl Generate a Certificate Revocation List (CRL)")
fmt.Println(" version Show version information")
fmt.Println() fmt.Println()
fmt.Println("Use 'lab-ca <command> --help' for more information about a command.") fmt.Println("Use 'lab-ca <command> --help' for more information about a command.")
} }

42
run-test.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
GREEN='\033[0;32m'
NC='\033[0m' # No Color
# Build and install
go install
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 provision --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