24 Commits

Author SHA1 Message Date
2ddd22631b Version v0.4.0
Some checks failed
Release / release (push) Failing after 38s
2025-08-04 22:13:35 +02:00
a2528e0526 Added a new test script.
Some checks failed
Release / release (push) Failing after 1m6s
2025-08-04 22:09:23 +02:00
9b0d1eceae Re-enginnered information output during issuance phase. 2025-08-04 22:09:11 +02:00
45dfdf0afc Removed --overwrite flag. 2025-08-03 12:02:47 +02:00
6e69377d1a Enhance SAN handling in issueSingleCertificate: extract CN from DN if present 2025-08-03 09:38:31 +02:00
8114d667ec Fixed a bug that prevented adding a mandatory SAN if the certificate type is server. 2025-08-03 07:37:55 +02:00
176901d960 Small fix for set-version.sh.
Some checks failed
Release / release (push) Failing after 45s
2025-08-02 13:57:27 +02:00
1991963cab Bug fixes for referening and not copying objects and few others. 2025-08-02 13:51:14 +02:00
b0f0467346 Added helper scripts that will lock and unlock changes to version.go. 2025-08-02 13:50:39 +02:00
028788f357 Updated build.sh to embed version information to the binary using changes to the version.go file. 2025-08-02 13:45:45 +02:00
090fb4b423 Moved Version variable to a separate file. 2025-08-02 13:42:25 +02:00
eb5c5c0e43 Updated tea repository name. 2025-07-30 09:01:00 +02:00
8181ac8287 Added missing go mod tidy. 2025-07-30 08:58:58 +02:00
3fe908226d Fixed unchange placeholder names. 2025-07-30 08:56:17 +02:00
8a36588c62 Added manual trigger. 2025-07-30 08:49:49 +02:00
90ce7edd28 Fixed incorrect workflow directory name. 2025-07-30 08:47:09 +02:00
11bed9c8b1 Added release workflow. 2025-07-30 08:37:21 +02:00
2fe228858f Update build process and version embedding. 2025-07-30 08:17:15 +02:00
e9d2634819 Updated documentation. 2025-07-28 21:27:05 +02:00
9d226df44f Fixes for bugs related to rendering certificate templates from defaults and variables. 2025-07-28 21:26:52 +02:00
71412ace5e Added example client,email certificate. 2025-07-28 21:25:23 +02:00
2350e3cbc1 Added a script that prints certificates. 2025-07-28 21:25:03 +02:00
d045938ff8 Updated test script 2025-07-28 21:24:46 +02:00
72424379e0 Moved test CA test location to 'ca' subdirectory. 2025-07-28 21:24:32 +02:00
14 changed files with 429 additions and 165 deletions

View File

@@ -0,0 +1,54 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
# 1. Checkout source code
- name: Checkout
uses: actions/checkout@v4
# 2. Setup Go environment
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.5'
# 3. Build binary with Version injected
- name: Build binary
run: |
VERSION=${GITEA_REF_NAME}
echo "Building version $VERSION"
go mod tidy
go build -ldflags "-s -w -X main.Version=$VERSION" -o lab-ca .
# 4. Install the tea CLI
- name: Install tea CLI
run: go install code.gitea.io/tea@latest
# 5. Authenticate tea CLI
- name: Login to Gitea
run: |
tea login add --name ci --url $GITEA_URL --token $GITEA_TOKEN
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ secrets.GITEA_URL }}
# 6. Create or update release
- name: Create or update release
run: |
tea release create $GITEA_REF_NAME \
--title "$GITEA_REF_NAME" \
--note "Automated release for $GITEA_REF_NAME" || \
echo "Release already exists, skipping create."
# 7. Upload binary to the release
- name: Upload binary
run: tea release upload $GITEA_REF_NAME lab-ca

6
.gitignore vendored
View File

@@ -2,6 +2,7 @@
**/go.sum **/go.sum
# Ignore the binary output # Ignore the binary output
lab-ca* lab-ca*
build
# Ignore any certificate files # Ignore any certificate files
*.pem *.pem
# Ignore CA configuration and certificate definitions. # Ignore CA configuration and certificate definitions.
@@ -13,5 +14,6 @@ lab-ca*
# Exclude MacOS Finder metadata files # Exclude MacOS Finder metadata files
.DS_Store .DS_Store
# Exclude default certificate and private key files directories # Exclude default certificate and private key files directories
/certs/ /ca
/private/ # Don't share VS Code files
.vscode/

206
README.md
View File

@@ -2,22 +2,23 @@
This repository contains a simple CLI tool for managing a Certificate Authority (CA). This repository contains a simple CLI tool for managing a Certificate Authority (CA).
It has been designed to be as easy to use as possible and provides a basic set of CA features: It is designed to be easy to use and provides a basic set of CA features:
- Create a CA and a self-signed CA certificate - Create a CA and a self-signed CA certificate
- Create and sign a few most common types of certificates: - Create and sign common types of certificates:
- Server certificate - Server certificate
- Client certificate - Client certificate
- Code signing certificate - Code signing certificate
- Email certificate - Email certificate
- Sign a CSR to create a certificate
- Revoke a certificate - Revoke a certificate
- List issued certificates - List issued certificates
- Create a CRL (Certificate Revocation List) - Create a CRL (Certificate Revocation List)
> **NOTE:** Certificate types can be combined (e.g. `server,client`).
## Usage ## Usage
The tool is designed to be used from the command line. It has a simple command structure: The tool is used from the command line. It has a simple command structure:
```bash ```bash
lab-ca <command> [options] lab-ca <command> [options]
@@ -25,23 +26,26 @@ lab-ca <command> [options]
The main commands available are: The main commands available are:
- `initca` — Initialize a new CA and create a self-signed CA certificate. - `initca` — Initialize a new CA and create a self-signed CA certificate and key.
- `issue` — Issue a new certificate signed by the CA. - `issue` — Issue a new certificate signed by the CA (single certificate, command-line options).
- `provision` — Provision multiple certificates from a batch file (HCL) in one go. - `provision` — Provision multiple certificates from a batch file (HCL) in one go.
- `revoke` — Revoke a certificate by name or serial number. - `revoke` — Revoke a certificate by name or serial number.
- `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates. - `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates.
- `list` — List issued certificates (optionally including revoked).
- `version` — Show version information for the tool. - `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 usage information. Each command has its own set of options, arguments, and a help message.
---
### CA Initialization ### CA Initialization
Create a new CA configuration file: Create a new CA configuration file (HCL):
``` ```hcl
ca "example_ca" { ca "example_ca" {
name = "Example CA" name = "Example CA"
country = "PL" country = "US"
organization = "ACME Corp" organization = "ACME Corp"
key_size = 4096 key_size = 4096
validity = "10y" validity = "10y"
@@ -49,72 +53,188 @@ ca "example_ca" {
paths { paths {
certificates = "certs" certificates = "certs"
private_keys = "private" private_keys = "private"
state_file = "ca_state.json"
} }
} }
``` ```
> **NOTE:** `lab-ca` uses HCL (HashiCorp Configuration Language) for configuration files. You can find more information about HCL [here](https://github.com/hashicorp/hcl). - The `ca` block's label is used as a logical name for the CA and for the default state file name.
- The `paths` block defines where certificates, private keys, and the CA state file are stored.
- On Linux/macOS, the private keys directory is created with `0700` permissions.
- The command does not encrypt private keys. Do not use in production.
The `ca` block's label has no function, but may be used to identify the CA in the future. ---
The following attributes are available: ### Listing Certificates
- `name` - the name of the CA List all issued (non-revoked) certificates:
- `country` - the country of the CA
- `organization` - the organization of the CA
- `organizational_unit` - the organizational unit of the CA (optional)
- `locality` - the locality of the CA (optional)
- `province` - the province of the CA (optional)
- `email` - the email address of the CA (optional)
- `key_size` - the size of the CA key in bits (default: 4096)
- `validity` - the validity period of the CA certificate (default: 10 years)
- `paths` - paths to store certificates and private keys
The `paths` block defines where the command will store the generated certificates and private keys. On Linux and macOS, the directory specified for private keys will be created with `0700` permissions. However, the command does not check if the directory has correct permissions, so you should ensure that the directory is not accessible by other users. On Windows, both directories will be created with the default ACL for the current user. You have to secure the private keys directory yourself. ```bash
lab-ca list
```
> **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment. To include revoked certificates:
## Certificate Issuance and Provisioning ```bash
lab-ca list --revoked
```
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 file: ### Issuing a Certificate
Issue a new certificate from the command line:
```bash
lab-ca issue --name <name> [--subject <subject>] [--type <type>] [--validity <period>] [--san <SAN> ...] [--overwrite] [--dry-run] [--verbose]
```
- `--name` (required): Name for the certificate and key files (used as subject if `--subject` is omitted)
- `--subject`: Subject Common Name or full DN (optional, defaults to `--name`)
- `--type`: Certificate type: `client`, `server`, `code-signing`, `email` (comma-separated for multiple usages; default: `server`)
- `--validity`: Validity period (e.g. `2y`, `6m`, `30d`; default: `1y`)
- `--san`: Subject Alternative Name (repeatable; e.g. `dns:example.com`, `ip:1.2.3.4`, `email:user@example.com`)
- `--overwrite`: Allow overwriting existing files
- `--dry-run`: Validate and show what would be created, but do not write files
- `--verbose`: Print detailed information
---
### Provisioning Certificates (Batch)
Provision multiple certificates from a batch file (HCL):
```bash
lab-ca provision --file <certificates.hcl> [--overwrite] [--verbose]
```
#### Example HCL Provisioning File
```hcl ```hcl
defaults { defaults {
subject = "{{ .Name }}.example.org" subject = "{{ .Name }}.example.org"
type = "server" type = "server,client"
validity = "1y" validity = "1y"
san = ["DNS:{{ .Name }}.example.org"] san = ["DNS:{{ .Name }}.example.org"]
} }
variables = { variables = {
Domain = "example.net" Domain = "example.net"
Country = "EX" Country = "EX"
} }
certificate "service1" { certificate "service1" {
# from default: subject = "{{ .Name }}.example.org" # from default: subject = "{{ .Name }}.example.org"
# from default: type = "server" # from default: type = "server,client"
# from default: validity = "1y" # from default: validity = "1y"
# from default: san = ["DNS:{{ .Name }}.example.org"] # from default: san = ["DNS:service1.example.org"]
} }
certificate "service2" { certificate "service2" {
subject = "{{ .Name }}.example.net" subject = "{{ .Name }}.{{ .Domain }}" # result: service2.example.net
# from default: type = "server" san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:service2.example.net" ]
# from default: validity = "1y"
san = ["DNS:{{ .Name }}.example.net"]
} }
certificate "service3" {} certificate "service3" {}
certificate "service4" { certificate "user1" {
subject = "{{ .Name }}.{{ .Domain }}" subject = "CN=User One,emailAddress=user1@example.org,O=Example,C=US"
san = ["DNS:{{ .Name }}.{{ .Domain }}"] type = "client,email"
validity = "1y"
san = ["email:user1@example.net"]
} }
``` ```
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. - The `defaults` block provides default values for all certificates unless overridden.
- The `variables` map can be used in Go template expressions in any field.
- Each `certificate` block defines a certificate to be issued. The block label is available as `{{ .Name }}` in templates.
- Fields:
- `subject`: Subject string (can be a template)
- `type`: Comma-separated usages (server, client, code-signing, email)
- `validity`: Validity period (e.g. `1y`, `6m`, `30d`)
- `san`: List of SANs (e.g. `DNS:...`, `IP:...`, `email:...`)
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. ---
### Revoking a Certificate
Revoke a certificate by name or serial number:
```bash
lab-ca revoke --name <name> [--reason <reason>]
lab-ca revoke --serial <serial> [--reason <reason>]
```
- `--reason` can be one of: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `removeFromCRL` (default: `cessationOfOperation`)
---
### Generating a CRL
Generate a Certificate Revocation List (CRL) from revoked certificates:
```bash
lab-ca crl [--crl-file <path>] [--validity-days <days>]
```
- `--crl-file`: Output path for CRL file (default: `crl.pem`)
- `--validity-days`: CRL validity in days (default: 30)
---
### Version
Show version information:
```bash
lab-ca version
```
---
## Configuration and Templates
- All configuration and provisioning files use HCL (HashiCorp Configuration Language).
- Go template syntax is supported in `subject`, `san`, and other string fields. The following variables are available:
- `.Name`: The certificate name (from the block label)
- Any key from the `variables` map
Example:
```hcl
subject = "{{ .Name }}.{{ .Domain }}"
san = ["DNS:{{ .Name }}.{{ .Domain }}"]
```
## Certificate Types and SANs
- `server`: For server certificates. SANs should be DNS or IP.
- `client`: For client certificates. SANs can be email or DNS.
- `email`: For S/MIME/email certificates. SANs should be email.
- `code-signing`: For code signing certificates.
The tool checks that SANs are valid for the selected certificate type(s). Certificate usage can be combined (e.g. `server,client`).
---
## Example: Real-World Provisioning File
See `examples/example-certificates.hcl` for a more advanced provisioning file with templates and variables.
## Building the Tool
The repository includes a `build.sh` script to build the CLI tool. It updates the version in `version.go` and builds the binary.
To ignore changes made to `version.go` in Git, you can run:
```bash
git update-index --assume-unchanged version.go
```
---
## Notes
- The tool does not encrypt private keys. Protect your private keys directory.
- Not intended for production use.
- For more information about HCL, see: https://github.com/hashicorp/hcl

View File

@@ -6,4 +6,21 @@ if [ $? -eq 0 ]; then
else else
VERSION="dev" VERSION="dev"
fi fi
go build -ldflags "-X main.Version=$VERSION" -o lab-ca
# Hardcode the version into main.go
sed -i '' "s/^var Version = .*/var Version = \"$VERSION\"/" version.go
if echo $VERSION | grep -q 'dirty$'; then
echo "Building in development mode, output directory is set to 'build'."
OUTPUT_DIR=build
# Make sure the output directory exists, create it if it is not
mkdir -p $OUTPUT_DIR
else
echo "Building with version: $VERSION"
OUTPUT_DIR=$GOHOME/bin
fi
# Build the Lab CA binary with version information
# go build -ldflags "-X main.Version=$VERSION" -o $OUTPUT_DIR/lab-ca
go build -o $OUTPUT_DIR/lab-ca

201
ca.go
View File

@@ -72,7 +72,7 @@ func (def *CertificateDefinition) FillDefaultValues(defaults *CertificateDefault
def.Validity = defaults.Validity def.Validity = defaults.Validity
} }
if len(def.SAN) == 0 && len(defaults.SAN) > 0 { if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
def.SAN = defaults.SAN def.SAN = append([]string(nil), defaults.SAN...)
} }
} }
@@ -291,14 +291,9 @@ func parseValidity(validity string) (time.Duration, error) {
} }
func SavePEM(filename string, data []byte, secure bool) error { func SavePEM(filename string, data []byte, secure bool) error {
if !overwrite { if _, err := os.Stat(filename); err == nil {
if _, err := os.Stat(filename); err == nil { return fmt.Errorf("file %s already exists", filename)
return fmt.Errorf("file %s already exists (overwrite not allowed)", filename)
} else if !os.IsNotExist(err) {
return fmt.Errorf("could not check file %s: %v", filename, err)
}
} }
if secure { if secure {
return os.WriteFile(filename, data, 0600) return os.WriteFile(filename, data, 0600)
} else { } else {
@@ -412,8 +407,9 @@ func InitCA() error {
NotBefore: now, NotBefore: now,
NotAfter: now.Add(validity), NotAfter: now.Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true, BasicConstraintsValid: true, // This is a CA certificate
IsCA: true, IsCA: true, // This is a CA certificate
MaxPathLenZero: true, // Allow issuing end-entity certificates
} }
// Add email if present // Add email if present
if caConfig.Email != "" { if caConfig.Email != "" {
@@ -452,22 +448,21 @@ func InitCA() error {
return nil return nil
} }
// Helper: issue a single certificate and key, save to files, return error if any func issueSingleCertificate(def CertificateDefinition, i int, n int) (bool, error) {
func issueSingleCertificate(def CertificateDefinition) error {
// Validate Name // Validate Name
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name) isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
if err != nil { if err != nil {
return fmt.Errorf("error validating certificate name: %v", err) return false, fmt.Errorf("error validating certificate name: %v", err)
} }
if !isValidName { if !isValidName {
return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore") return false, fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore")
} }
// Check if the certificate is in database, fail if it is. // Check if the certificate is in database, fail if it is.
if caState.FindByName(def.Name, false) != nil { if caState.FindByName(def.Name, false) != nil {
return fmt.Errorf("certificate %s already exists and is valid.", def.Name) return false, fmt.Errorf("certificate %s already exists and is valid.", def.Name)
} }
// Initialize Subject if not specified // Initialize Subject if not specified
@@ -476,18 +471,44 @@ func issueSingleCertificate(def CertificateDefinition) error {
} }
// Add default dns SAN for server/server-only if none specified // Add default dns SAN for server/server-only if none specified
if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 { if strings.Contains(def.Type, "server") && len(def.SAN) == 0 {
def.SAN = append(def.SAN, "dns:"+def.Subject) // Extract CN if subject is a DN, else use subject as is
cn := def.Subject
if isDNFormat(def.Subject) {
dn := parseDistinguishedName(def.Subject)
if dn.CommonName != "" {
cn = dn.CommonName
}
}
def.SAN = append(def.SAN, "dns:"+cn)
}
// Check if the certificate is being issued in dry run mode
if dryRun {
msg := fmt.Sprintf("Would issue certificate for '%s' (dry run).", def.Subject)
if n > 1 {
fmt.Printf("[%d/%d] %s\n", i+1, n, msg)
} else {
fmt.Printf("%s\n", msg)
}
return false, nil
} else {
msg := fmt.Sprintf("Issuing certificate for '%s'... ", def.Subject)
if n > 1 {
fmt.Printf("[%d/%d] %s", i+1, n, msg)
} else {
fmt.Printf("%s", msg)
}
} }
priv, err := rsa.GenerateKey(rand.Reader, 4096) priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil { if err != nil {
return fmt.Errorf("failed to generate private key: %v", err) return false, fmt.Errorf("failed to generate private key: %v", err)
} }
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil { if err != nil {
return fmt.Errorf("failed to generate serial number: %v", err) return false, fmt.Errorf("failed to generate serial number: %v", err)
} }
var validityDur time.Duration var validityDur time.Duration
@@ -498,7 +519,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
validityDur, err = parseValidity(validity) validityDur, err = parseValidity(validity)
if err != nil { if err != nil {
return fmt.Errorf("invalid validity value: %v", err) return false, fmt.Errorf("invalid validity value: %v", err)
} }
var subjectPKIX pkix.Name var subjectPKIX pkix.Name
@@ -512,11 +533,13 @@ func issueSingleCertificate(def CertificateDefinition) error {
expires := dateIssued.Add(validityDur) expires := dateIssued.Add(validityDur)
certTmpl := x509.Certificate{ certTmpl := x509.Certificate{
SerialNumber: serialNumber, SerialNumber: serialNumber,
Subject: subjectPKIX, Subject: subjectPKIX,
NotBefore: dateIssued, NotBefore: dateIssued,
NotAfter: expires, NotAfter: expires,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
IsCA: false,
BasicConstraintsValid: true,
} }
for _, s := range def.SAN { for _, s := range def.SAN {
@@ -529,7 +552,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 { } else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val) certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
} else { } else {
return fmt.Errorf("invalid SAN format: %s", s) return false, fmt.Errorf("invalid SAN format: %s", s)
} }
} }
@@ -549,13 +572,13 @@ func issueSingleCertificate(def CertificateDefinition) error {
case "email": case "email":
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection) certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
default: default:
return fmt.Errorf("unknown certificate type. Use one of: client, server, code-signing, email") return false, fmt.Errorf("unknown certificate type. Use one of: client, server, code-signing, email")
} }
} }
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey) certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to create certificate: %v", err) return false, fmt.Errorf("failed to create certificate: %v", err)
} }
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
@@ -563,28 +586,12 @@ func issueSingleCertificate(def CertificateDefinition) error {
certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem") certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem")
keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem") keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
if err := SavePEM(certFile, certPEM, false); err != nil { if err := SavePEM(certFile, certPEM, false); err != nil {
return fmt.Errorf("error saving certificate: %v", err) return false, fmt.Errorf("error saving certificate: %v", err)
} }
if err := SavePEM(keyFile, keyPEM, true); err != nil { if err := SavePEM(keyFile, keyPEM, true); err != nil {
return fmt.Errorf("error saving key: %v", err) return false, fmt.Errorf("error saving key: %v", err)
} }
if verbose { err = caState.UpdateCAStateAfterIssue(
fmt.Printf(`
Certificate:
Name: %s
Subject: %s
Type: %s
Validity: %s
SAN: %v
`,
def.Name,
def.Subject,
def.Type,
def.Validity,
def.SAN,
)
}
caState.UpdateCAStateAfterIssue(
caConfig.SerialType, caConfig.SerialType,
def.Name, def.Name,
def.Subject, def.Subject,
@@ -592,11 +599,49 @@ Certificate:
serialNumber, serialNumber,
validityDur, validityDur,
) )
return nil
if err != nil {
return false, fmt.Errorf("error updating CA state: %v", err)
}
if !verbose {
fmt.Printf("done.\n")
} else {
fmt.Printf(`done.
Certificate generated:
Name: %s
Subject: %s
Type: %s
Validity: %s
SANs:
`,
def.Name,
def.Subject,
def.Type,
def.Validity,
)
for _, san := range def.SAN {
parts := strings.SplitN(san, ":", 2)
if len(parts) == 2 {
fmt.Printf(" %s (%s)\n", parts[1], parts[0])
} else {
fmt.Printf(" %s\n", san)
}
}
}
if err := SaveCAState(); err != nil {
// If saving CA state fails, we still return success for the certificate issuance
fmt.Printf("WARNING: %v\n", err)
fmt.Println("CA state not saved, but certificate issued and saved successfully.")
return true, fmt.Errorf("Error saving CA state: %v", err)
}
return true, nil
} }
// A prototype of certificate provisioning function // A prototype of certificate provisioning function
func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose bool) error { func ProvisionCertificates(filePath string) error {
err := LoadCA() err := LoadCA()
if err != nil { if err != nil {
@@ -624,46 +669,37 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
// Loop through all certificate definitions // Loop through all certificate definitions
// to render templates and fill missing fields from defaults // to render templates and fill missing fields from defaults
for _, def := range certDefs.Certificates { for i := range certDefs.Certificates {
// Fill missing fields from defaults, if provided // Fill missing fields from defaults, if provided
def.FillDefaultValues(certDefs.Defaults) certDefs.Certificates[i].FillDefaultValues(certDefs.Defaults)
// Render templates in the definition using the variables map // Render templates in the definition using the variables map
// with added definition name. // with added definition name.
variables := certDefs.Variables variables := certDefs.Variables
if variables == nil { if variables == nil {
variables = make(map[string]string) variables = make(map[string]string)
} }
variables["Name"] = def.Name variables["Name"] = certDefs.Certificates[i].Name
err = def.RenderTemplates(variables) err = certDefs.Certificates[i].RenderTemplates(variables)
if err != nil { if err != nil {
return fmt.Errorf("failed to render templates for certificate %s: %v", def.Name, err) return fmt.Errorf("failed to render templates for certificate %s: %v", certDefs.Certificates[i].Name, err)
} }
} }
n := len(certDefs.Certificates) n := len(certDefs.Certificates)
// No errors so far, now we can issue certificates // No errors so far, now we can issue certificates
for i, def := range certDefs.Certificates { for i := range certDefs.Certificates {
fmt.Printf("[%d/%d] Issuing %s... ", i+1, n, def.Name) issued, err := issueSingleCertificate(certDefs.Certificates[i], i, n)
if dryRun {
fmt.Printf("(dry run)\n")
successes++
continue
}
err = issueSingleCertificate(def)
if err != nil { if err != nil {
fmt.Printf("error: %v\n", err) fmt.Printf("error: %v\n", err)
errors++ errors++
} else { } else {
if !verbose { if issued {
fmt.Printf("done\n") successes++
} }
successes++
} }
} }
fmt.Printf("Provisioning complete: %d succeeded, %d failed.\n", successes, errors) fmt.Printf("Provisioning complete: %d succeeded, %d failed, %d skipped.\n", successes, errors, n-successes-errors)
err = SaveCAState() err = SaveCAState()
if err != nil { if err != nil {
@@ -673,38 +709,23 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
return nil return nil
} }
func IssueCertificate(certDef CertificateDefinition, overwrite bool, dryRun bool, verbose bool) error { func IssueCertificate(def CertificateDefinition) error {
err := LoadCA() err := LoadCA()
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1) os.Exit(1)
} }
if certDef.Subject == "" { if def.Subject == "" {
certDef.Subject = certDef.Name def.Subject = def.Name
} }
// Render templates in the certificae subject and SAN fields // Render templates in the certificae subject and SAN fields
variables := map[string]string{"Name": certDef.Name} variables := map[string]string{"Name": def.Name}
certDef.RenderTemplates(variables) def.RenderTemplates(variables)
if dryRun { _, err = issueSingleCertificate(def, 1, 1)
fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", certDef.Type, certDef.Subject) return err
return nil
}
err = issueSingleCertificate(certDef)
if err != nil {
return err
}
fmt.Printf("%s certificate and key for '%s' generated.\n", certDef.Type, certDef.Subject)
if err := SaveCAState(); err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
}
return nil
} }
// Helper: check if string looks like a DN (contains at least CN=...) // Helper: check if string looks like a DN (contains at least CN=...)

View File

@@ -36,41 +36,42 @@ type CertificateRecord struct {
// Look for a certifcate by its name // Look for a certifcate by its name
func (c *CAState) FindByName(name string, all bool) *CertificateRecord { func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
for _, cert := range c.Certificates { for i := range c.Certificates {
cert := &c.Certificates[i]
if cert.RevokedAt != "" && !all { if cert.RevokedAt != "" && !all {
continue continue
} }
if cert.Name == name { if cert.Name == name {
return &cert return cert
} }
} }
return nil return nil
} }
// Look for a certificate by its serial // Look for a certificate by its serial
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord { func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
for _, cert := range c.Certificates { for i := range c.Certificates {
cert := &c.Certificates[i]
if cert.RevokedAt != "" && !all { if cert.RevokedAt != "" && !all {
continue continue
} }
if cert.Serial == serial { if cert.Serial == serial {
return &cert return cert
} }
} }
return nil return nil
} }
// func caStatePath() string {
// return filepath.Join(filepath.Dir(caConfigPath), caConfig.GetStateFileName())
// }
// LoadCAState loads the CA state from a JSON file // LoadCAState loads the CA state from a JSON file
func LoadCAState() error { func LoadCAState() error {
fmt.Printf("Loading CA state from %s\n", caStatePath) fmt.Printf("Loading CA state from %s\n", caStatePath)
f, err := os.Open(caStatePath) f, err := os.Open(caStatePath)
if err != nil { if err != nil {
if os.IsNotExist(err) {
// File does not exist, treat as empty state
caState = &CAState{}
return nil
}
return err return err
} }
defer f.Close() defer f.Close()
@@ -97,8 +98,7 @@ func SaveCAState() error {
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate // 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 { func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject string, certType string, serialNumber any, validity time.Duration) error {
if s == nil { if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n") return fmt.Errorf("CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.")
os.Exit(1)
} }
issued := time.Now().UTC().Format(time.RFC3339) issued := time.Now().UTC().Format(time.RFC3339)
expires := time.Now().Add(validity).UTC().Format(time.RFC3339) expires := time.Now().Add(validity).UTC().Format(time.RFC3339)
@@ -119,7 +119,7 @@ func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject strin
func (s *CAState) AddCertificate(name, subject, certType, issued, expires, serial string) { func (s *CAState) AddCertificate(name, subject, certType, issued, expires, serial string) {
if s == nil { if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n") fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
os.Exit(1) return
} }
rec := CertificateRecord{ rec := CertificateRecord{
Name: name, Name: name,
@@ -135,8 +135,7 @@ func (s *CAState) AddCertificate(name, subject, certType, issued, expires, seria
// RevokeCertificate revokes a certificate by serial number and reason code, updates state, and saves to disk // 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 { func (s *CAState) RevokeCertificate(serial string, reason int) error {
if s == nil { if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n") return fmt.Errorf("CAState is nil in RevokeCertificate. This indicates a programming error.")
os.Exit(1)
} }
revoked := false revoked := false
revokedAt := time.Now().UTC().Format(time.RFC3339) revokedAt := time.Now().UTC().Format(time.RFC3339)

View File

@@ -1,6 +1,6 @@
defaults { defaults {
subject = "{{ .Name }}.koszewscy.waw.pl" subject = "{{ .Name }}.koszewscy.waw.pl"
type = "server" type = "server,client"
validity = "1y" validity = "1y"
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"] san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
} }
@@ -30,3 +30,10 @@ certificate "prometheus" {
subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ] san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ]
} }
certificate "slawek" {
subject = "CN=Slawomir Koszewski,emailAddress=slawek@koszewscy.waw.pl,O=Koszewscy,C=PL"
type = "client,email"
validity = "1y"
san = ["email:slawek@koszewscy.email"]
}

View File

@@ -0,0 +1,3 @@
#!/bin/bash
git update-index --assume-unchanged version.go

20
main.go
View File

@@ -7,10 +7,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var Version = "dev"
// Global flags available to all commands // Global flags available to all commands
var overwrite bool
var dryRun bool var dryRun bool
var verbose bool var verbose bool
@@ -48,7 +45,6 @@ func main() {
} }
// Define persistent flags (global for all commands) // 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(&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().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") rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
@@ -97,7 +93,7 @@ func main() {
Type: certType, Type: certType,
Validity: validity, Validity: validity,
SAN: san, SAN: san,
}, overwrite, dryRun, verbose) })
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
@@ -122,7 +118,7 @@ func main() {
Short: "Provision certificates from a batch file (HCL)", Short: "Provision certificates from a batch file (HCL)",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
err := ProvisionCertificates(provisionFile, overwrite, false, verbose) err := ProvisionCertificates(provisionFile)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
@@ -225,7 +221,7 @@ func main() {
Use: "version", Use: "version",
Short: "Show version information", Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("lab-ca version: %s\n", Version) fmt.Printf("lab-ca version: %s\n", getVersionDescription())
}, },
} }
rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(versionCmd)
@@ -235,15 +231,23 @@ func main() {
} }
} }
func getVersionDescription() string {
if Version == "" {
return "no version information was compiled in"
}
return Version
}
func printMainHelp() { func printMainHelp() {
fmt.Printf("lab-ca - Certificate Authority Utility\n") fmt.Printf("lab-ca - Certificate Authority Utility\n")
fmt.Printf("Version: %s\n", Version) fmt.Printf("Version: %s\n", getVersionDescription())
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(" list List issued certificates")
fmt.Println(" issue Issue a new certificate") fmt.Println(" issue Issue a new certificate")
fmt.Println(" provision Provision certificates from a batch file (HCL)") fmt.Println(" provision Provision certificates from a batch file (HCL)")
fmt.Println(" revoke Revoke a certificate by name or serial number") fmt.Println(" revoke Revoke a certificate by name or serial number")

11
print-certificates.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
GREEN='\033[0;32m'
NC='\033[0m' # No Color
for cert in ca/certs/*.pem
do
echo -e "${GREEN}----- Certificate: $(basename $cert)${NC}"
openssl x509 -in "$cert" -noout -text
echo -e "${GREEN}----- End of Certificate -----${NC}\n"
done

13
run-test-2.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
if [ "-v" == "$1" ]; then
VERBOSE="--verbose"
shift
else
VERBOSE=""
fi
./build.sh
rm -rf docker_ca
build/lab-ca $VERBOSE --config docker_ca.hcl initca
build/lab-ca $VERBOSE --config docker_ca.hcl provision --file docker.hcl

View File

@@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
GREEN='\033[0;32m' GREEN='\033[0;32m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
LAB_CA="./lab-ca" LAB_CA="build/lab-ca"
PROVISION_CONFIG="examples/example-certificates.hcl"
# Build and install # Build and install
# Build script for lab-ca with version injection from git tag # Build script for lab-ca with version injection from git tag
git describe --tags --always --dirty > /dev/null 2>&1 git describe --tags --always --dirty > /dev/null 2>&1
@@ -18,7 +18,7 @@ if [ $? -ne 0 ]; then
fi fi
echo -e "${GREEN}Build successful! Version: $VERSION${NC}" echo -e "${GREEN}Build successful! Version: $VERSION${NC}"
rm -rf certs private *.json crl*.pem rm -rf ca
echo -e "\n${GREEN}Initializing CA...${NC}" echo -e "\n${GREEN}Initializing CA...${NC}"
$LAB_CA initca || exit 1 $LAB_CA initca || exit 1
@@ -36,7 +36,7 @@ echo -e "\n${GREEN}Issuing single certificate..${NC}"
$LAB_CA issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1 $LAB_CA issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1
echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}" echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}"
$LAB_CA provision --file examples/example-certificates.hcl --verbose || exit 1 $LAB_CA provision --file $PROVISION_CONFIG --verbose || exit 1
echo -e "\n${GREEN}Revoking a certificate by name...${NC}" echo -e "\n${GREEN}Revoking a certificate by name...${NC}"
$LAB_CA revoke --name "loki" || exit 1 $LAB_CA revoke --name "loki" || exit 1
@@ -53,4 +53,7 @@ $LAB_CA crl --validity-days 7 --crl-file crl-2.pem || exit 1
openssl crl -noout -text -in crl-2.pem openssl crl -noout -text -in crl-2.pem
echo -e "\n${GREEN}Dumping CA state...${NC}" echo -e "\n${GREEN}Dumping CA state...${NC}"
jq '.' example_ca_state.json jq '.' ca/ca_state.json
# Finished
echo -e "\n${GREEN}All operations completed successfully!${NC}"

7
set-version.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
VERSION=${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
# Allow git to track changes to version.go
git update-index --no-assume-unchanged version.go
# Hardcode the version into main.go
sed -i '' "s/^var Version = .*/var Version = \"$VERSION\"/" version.go

3
version.go Normal file
View File

@@ -0,0 +1,3 @@
package main
var Version = "v0.4.0"