Compare commits

...

8 Commits

Author SHA1 Message Date
0a71661901 CHANGELOG: bump to v1.13.2. 2025-11-17 15:50:51 -08:00
804f53d27d Refactor bundling into separate package. 2025-11-17 15:08:10 -08:00
cfb80355bb Update CHANGELOG for v1.13.1. 2025-11-17 10:08:05 -08:00
77160395a0 Cleaning up a few things. 2025-11-17 10:07:03 -08:00
37d5e04421 Adding Dockerfile 2025-11-17 09:03:43 -08:00
dc54eeacbc Remove cert bundles generated in testdata. 2025-11-17 08:36:31 -08:00
e2a3081ce5 cmd: add certser command. 2025-11-17 07:18:46 -08:00
3149d958f4 cmd: add certser 2025-11-17 06:55:20 -08:00
16 changed files with 977 additions and 738 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.idea
cmd/cert-bundler/testdata/pkg/*

View File

@@ -390,6 +390,9 @@ linters:
- 3
- 4
- 8
- 24
- 30
- 365
nakedret:
# Make an issue if func has more lines of code than this setting, and it has naked returns.

View File

@@ -1,5 +1,26 @@
CHANGELOG
v1.13.2 - 2025-11-17
Add:
- certlib/bundler: refactor certificate bundling from cmd/cert-bundler
into a separate package.
Changed:
- cmd/cert-bundler: refactor to use bundler package, and update Dockerfile.
v1.13.1 - 2025-11-17
Add:
- Dockerfile for cert-bundler.
v1.13.0 - 2025-11-16
Add:
- cmd/certser: print serial numbers for certificates.
- lib/HexEncode: add a new hex encode function handling multiple output
formats, including with and without colons.
v1.12.4 - 2025-11-16
Changed:

668
certlib/bundler/bundler.go Normal file
View File

@@ -0,0 +1,668 @@
package bundler
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v2"
"git.wntrmute.dev/kyle/goutils/certlib"
)
const defaultFileMode = 0644
// Config represents the top-level YAML configuration.
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains.
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates.
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options.
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
// Run performs the bundling operation given a config file path and an output directory.
func Run(configFile string, outputDir string) error {
if configFile == "" {
return errors.New("configuration file required")
}
cfg, err := loadConfig(configFile)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
return fmt.Errorf("parsing expiry: %w", err)
}
}
if err = os.MkdirAll(outputDir, 0750); err != nil {
return fmt.Errorf("creating output directory: %w", err)
}
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, perr := processChainGroup(groupName, group, expiryDuration, outputDir)
if perr != nil {
return fmt.Errorf("processing chain group %s: %w", groupName, perr)
}
createdFiles = append(createdFiles, files...)
}
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if gerr := generateHashFile(hashFile, createdFiles); gerr != nil {
return fmt.Errorf("generating hash file: %w", gerr)
}
}
return nil
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if uerr := yaml.Unmarshal(data, &cfg); uerr != nil {
return nil, uerr
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(
groupName string,
group ChainGroup,
expiryDuration time.Duration,
outputDir string,
) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, sourcePaths, err := loadAndCollectCerts(
group.Certs,
group.Outputs,
expiryDuration,
)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, sourcePaths, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles, outputDir)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing.
func loadAndCollectCerts(
chains []CertChain,
outputs Outputs,
expiryDuration time.Duration,
) ([]*x509.Certificate, []certWithPath, []string, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
var sourcePaths []string
for _, chain := range chains {
s, i, cerr := collectFromChain(chain, outputs, expiryDuration)
if cerr != nil {
return nil, nil, nil, cerr
}
if len(s) > 0 {
singleFileCerts = append(singleFileCerts, s...)
}
if len(i) > 0 {
individualCerts = append(individualCerts, i...)
}
// Record source paths for timestamp preservation
// Only append when loading succeeded
sourcePaths = append(sourcePaths, chain.Root)
sourcePaths = append(sourcePaths, chain.Intermediates...)
}
return singleFileCerts, individualCerts, sourcePaths, nil
}
// collectFromChain loads a single chain, performs checks, and returns the certs to include.
func collectFromChain(
chain CertChain,
outputs Outputs,
expiryDuration time.Duration,
) (
[]*x509.Certificate,
[]certWithPath,
error,
) {
var single []*x509.Certificate
var indiv []certWithPath
// Load root certificate
rootCert, rerr := certlib.LoadCertificate(chain.Root)
if rerr != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %w", chain.Root, rerr)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
single = append(single, rootCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: rootCert, path: chain.Root})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, lerr := certlib.LoadCertificate(intPath)
if lerr != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %w", intPath, lerr)
}
// Validate that intermediate is signed by root
if sigErr := intCert.CheckSignatureFrom(rootCert); sigErr != nil {
return nil, nil, fmt.Errorf(
"intermediate %s is not properly signed by root %s: %w",
intPath,
chain.Root,
sigErr,
)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
single = append(single, intCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: intCert, path: intPath})
}
}
return single, indiv, nil
}
// prepareArchiveFiles prepares all files to be included in archives.
func prepareArchiveFiles(
singleFileCerts []*x509.Certificate,
individualCerts []certWithPath,
sourcePaths []string,
outputs Outputs,
encoding string,
) ([]fileEntry, error) {
var archiveFiles []fileEntry
// Track used filenames to avoid collisions inside archives
usedNames := make(map[string]int)
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
bundleTime := maxModTime(sourcePaths)
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
}
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
files[i].modTime = bundleTime
// Best-effort: we do not have a portable birth/creation time.
// Use the same timestamp for created time to track deterministically.
files[i].createTime = bundleTime
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
}
mt := fileModTime(cp.path)
for i := range files {
files[i].name = makeUniqueName(files[i].name, usedNames)
files[i].modTime = mt
files[i].createTime = mt
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
manifestName := makeUniqueName("MANIFEST", usedNames)
mt := maxModTime(sourcePaths)
archiveFiles = append(archiveFiles, fileEntry{
name: manifestName,
content: manifestContent,
modTime: mt,
createTime: mt,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats.
func createArchiveFiles(
groupName string,
formats []string,
archiveFiles []fileEntry,
outputDir string,
) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %w", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: Certificate %s has EXPIRED (expired %d days ago)\n",
path,
-daysUntilExpiry,
)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
modTime time.Time
createTime time.Time
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file.
func encodeCertsToFiles(
certs []*x509.Certificate,
baseName string,
encoding string,
isSingle bool,
) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
// Individual DER file (should only have one cert)
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format.
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
var manifest strings.Builder
for _, file := range files {
if file.name == "MANIFEST" {
continue
}
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
// closeWithErr attempts to close all provided closers, joining any close errors with baseErr.
func closeWithErr(baseErr error, closers ...io.Closer) error {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
baseErr = errors.Join(baseErr, cerr)
}
}
return baseErr
}
func createZipArchive(path string, files []fileEntry) error {
f, zerr := os.Create(path)
if zerr != nil {
return zerr
}
w := zip.NewWriter(f)
for _, file := range files {
hdr := &zip.FileHeader{
Name: file.name,
Method: zip.Deflate,
}
if !file.modTime.IsZero() {
hdr.SetModTime(file.modTime)
}
fw, werr := w.CreateHeader(hdr)
if werr != nil {
return closeWithErr(werr, w, f)
}
if _, werr = fw.Write(file.content); werr != nil {
return closeWithErr(werr, w, f)
}
}
// Check errors on close operations
if cerr := w.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, terr := os.Create(path)
if terr != nil {
return terr
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Uid: 0,
Gid: 0,
Mode: defaultFileMode,
Size: int64(len(file.content)),
ModTime: func() time.Time {
if file.modTime.IsZero() {
return time.Now()
}
return file.modTime
}(),
}
// Set additional times if supported
hdr.AccessTime = hdr.ModTime
if !file.createTime.IsZero() {
hdr.ChangeTime = file.createTime
} else {
hdr.ChangeTime = hdr.ModTime
}
if herr := tw.WriteHeader(hdr); herr != nil {
return closeWithErr(herr, tw, gw, f)
}
if _, werr := tw.Write(file.content); werr != nil {
return closeWithErr(werr, tw, gw, f)
}
}
// Check errors on close operations in the correct order
if cerr := tw.Close(); cerr != nil {
_ = gw.Close()
_ = f.Close()
return cerr
}
if cerr := gw.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, rerr := os.ReadFile(file)
if rerr != nil {
return rerr
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}
// makeUniqueName ensures that each file name within the archive is unique by appending
// an incremental numeric suffix before the extension when collisions occur.
// Example: "root.pem" -> "root-2.pem", "root-3.pem", etc.
func makeUniqueName(name string, used map[string]int) string {
// If unused, mark and return as-is
if _, ok := used[name]; !ok {
used[name] = 1
return name
}
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
// Track a counter per base+ext key
key := base + ext
counter := max(used[key], 1)
for {
counter++
candidate := fmt.Sprintf("%s-%d%s", base, counter, ext)
if _, exists := used[candidate]; !exists {
used[key] = counter
used[candidate] = 1
return candidate
}
}
}
// fileModTime returns the file's modification time, or time.Now() if stat fails.
func fileModTime(path string) time.Time {
fi, err := os.Stat(path)
if err != nil {
return time.Now()
}
return fi.ModTime()
}
// maxModTime returns the latest modification time across provided paths.
// If the list is empty or stats fail, returns time.Now().
func maxModTime(paths []string) time.Time {
var zero time.Time
maxTime := zero
for _, p := range paths {
fi, err := os.Stat(p)
if err != nil {
continue
}
mt := fi.ModTime()
if maxTime.IsZero() || mt.After(maxTime) {
maxTime = mt
}
}
if maxTime.IsZero() {
return time.Now()
}
return maxTime
}

View File

@@ -0,0 +1,28 @@
# Build and runtime image for cert-bundler
# Usage (from repo root or cmd/cert-bundler directory):
# docker build -t cert-bundler:latest -f cmd/cert-bundler/Dockerfile .
# docker run --rm -v "$PWD":/work cert-bundler:latest
# This expects a /work/bundle.yaml file in the mounted directory and
# will write generated bundles to /work/bundle.
# Build stage
FROM golang:1.24.3-alpine AS build
WORKDIR /src
# Copy go module files and download dependencies first for better caching
RUN go install git.wntrmute.dev/kyle/goutils/cmd/cert-bundler@v1.13.2 && \
mv /go/bin/cert-bundler /usr/local/bin/cert-bundler
# Runtime stage (kept as golang:alpine per requirement)
FROM golang:1.24.3-alpine
# Create a work directory that users will typically mount into
WORKDIR /work
VOLUME ["/work"]
# Copy the built binary from the builder stage
COPY --from=build /usr/local/bin/cert-bundler /usr/local/bin/cert-bundler
# Default command: read bundle.yaml from current directory and output to ./bundle
ENTRYPOINT ["/usr/local/bin/cert-bundler"]
CMD ["-c", "/work/bundle.yaml", "-o", "/work/bundle"]

View File

@@ -1,66 +1,19 @@
package main
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"crypto/sha256"
"crypto/x509"
_ "embed"
"encoding/pem"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
"gopkg.in/yaml.v2"
"git.wntrmute.dev/kyle/goutils/certlib/bundler"
)
// Config represents the top-level YAML configuration.
type Config struct {
Config struct {
Hashes string `yaml:"hashes"`
Expiry string `yaml:"expiry"`
} `yaml:"config"`
Chains map[string]ChainGroup `yaml:"chains"`
}
// ChainGroup represents a named group of certificate chains.
type ChainGroup struct {
Certs []CertChain `yaml:"certs"`
Outputs Outputs `yaml:"outputs"`
}
// CertChain represents a root certificate and its intermediates.
type CertChain struct {
Root string `yaml:"root"`
Intermediates []string `yaml:"intermediates"`
}
// Outputs defines output format options.
type Outputs struct {
IncludeSingle bool `yaml:"include_single"`
IncludeIndividual bool `yaml:"include_individual"`
Manifest bool `yaml:"manifest"`
Formats []string `yaml:"formats"`
Encoding string `yaml:"encoding"`
}
var (
configFile string
outputDir string
)
var formatExtensions = map[string]string{
"zip": ".zip",
"tgz": ".tar.gz",
}
//go:embed README.txt
var readmeContent string
@@ -79,497 +32,10 @@ func main() {
os.Exit(1)
}
// Load and parse configuration
cfg, err := loadConfig(configFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
if err := bundler.Run(configFile, outputDir); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Parse expiry duration (default 1 year)
expiryDuration := 365 * 24 * time.Hour
if cfg.Config.Expiry != "" {
expiryDuration, err = parseDuration(cfg.Config.Expiry)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing expiry: %v\n", err)
os.Exit(1)
}
}
// Create output directory if it doesn't exist
err = os.MkdirAll(outputDir, 0750)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
os.Exit(1)
}
// Process each chain group
// Pre-allocate createdFiles based on total number of formats across all groups
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains {
files, perr := processChainGroup(groupName, group, expiryDuration)
if perr != nil {
fmt.Fprintf(os.Stderr, "Error processing chain group %s: %v\n", groupName, perr)
os.Exit(1)
}
createdFiles = append(createdFiles, files...)
}
// Generate hash file for all created archives
if cfg.Config.Hashes != "" {
hashFile := filepath.Join(outputDir, cfg.Config.Hashes)
if gerr := generateHashFile(hashFile, createdFiles); gerr != nil {
fmt.Fprintf(os.Stderr, "Error generating hash file: %v\n", gerr)
os.Exit(1)
}
}
fmt.Println("Certificate bundling completed successfully")
}
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if uerr := yaml.Unmarshal(data, &cfg); uerr != nil {
return nil, uerr
}
return &cfg, nil
}
func parseDuration(s string) (time.Duration, error) {
// Support simple formats like "1y", "6m", "30d"
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration format: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'y', 'Y':
multiplier = 365 * 24 * time.Hour
case 'm', 'M':
multiplier = 30 * 24 * time.Hour
case 'd', 'D':
multiplier = 24 * time.Hour
default:
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", s)
}
return time.Duration(num) * multiplier, nil
}
func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) {
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
encoding = "pem"
}
// Collect certificates from all chains in the group
singleFileCerts, individualCerts, err := loadAndCollectCerts(group.Certs, group.Outputs, expiryDuration)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing.
func loadAndCollectCerts(
chains []CertChain,
outputs Outputs,
expiryDuration time.Duration,
) ([]*x509.Certificate, []certWithPath, error) {
var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath
for _, chain := range chains {
s, i, cerr := collectFromChain(chain, outputs, expiryDuration)
if cerr != nil {
return nil, nil, cerr
}
if len(s) > 0 {
singleFileCerts = append(singleFileCerts, s...)
}
if len(i) > 0 {
individualCerts = append(individualCerts, i...)
}
}
return singleFileCerts, individualCerts, nil
}
// collectFromChain loads a single chain, performs checks, and returns the certs to include.
func collectFromChain(
chain CertChain,
outputs Outputs,
expiryDuration time.Duration,
) (
[]*x509.Certificate,
[]certWithPath,
error,
) {
var single []*x509.Certificate
var indiv []certWithPath
// Load root certificate
rootCert, rerr := certlib.LoadCertificate(chain.Root)
if rerr != nil {
return nil, nil, fmt.Errorf("failed to load root certificate %s: %w", chain.Root, rerr)
}
// Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to collections if needed
if outputs.IncludeSingle {
single = append(single, rootCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: rootCert, path: chain.Root})
}
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, lerr := certlib.LoadCertificate(intPath)
if lerr != nil {
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %w", intPath, lerr)
}
// Validate that intermediate is signed by root
if sigErr := intCert.CheckSignatureFrom(rootCert); sigErr != nil {
return nil, nil, fmt.Errorf(
"intermediate %s is not properly signed by root %s: %w",
intPath,
chain.Root,
sigErr,
)
}
// Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to collections if needed
if outputs.IncludeSingle {
single = append(single, intCert)
}
if outputs.IncludeIndividual {
indiv = append(indiv, certWithPath{cert: intCert, path: intPath})
}
}
return single, indiv, nil
}
// prepareArchiveFiles prepares all files to be included in archives.
func prepareArchiveFiles(
singleFileCerts []*x509.Certificate,
individualCerts []certWithPath,
outputs Outputs,
encoding string,
) ([]fileEntry, error) {
var archiveFiles []fileEntry
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
}
archiveFiles = append(archiveFiles, files...)
}
// Handle individual files
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
if err != nil {
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
}
archiveFiles = append(archiveFiles, files...)
}
}
// Generate manifest if requested
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
archiveFiles = append(archiveFiles, fileEntry{
name: "MANIFEST",
content: manifestContent,
})
}
return archiveFiles, nil
}
// createArchiveFiles creates archive files in the specified formats.
func createArchiveFiles(groupName string, formats []string, archiveFiles []fileEntry) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
}
archivePath := filepath.Join(outputDir, groupName+ext)
switch format {
case "zip":
if err := createZipArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create zip archive: %w", err)
}
case "tgz":
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
return nil, fmt.Errorf("failed to create tar.gz archive: %w", err)
}
default:
return nil, fmt.Errorf("unsupported format: %s", format)
}
createdFiles = append(createdFiles, archivePath)
}
return createdFiles, nil
}
func checkExpiry(path string, cert *x509.Certificate, expiryDuration time.Duration) {
now := time.Now()
expiryThreshold := now.Add(expiryDuration)
if cert.NotAfter.Before(expiryThreshold) {
daysUntilExpiry := int(cert.NotAfter.Sub(now).Hours() / 24)
if daysUntilExpiry < 0 {
fmt.Fprintf(
os.Stderr,
"WARNING: Certificate %s has EXPIRED (expired %d days ago)\n",
path,
-daysUntilExpiry,
)
} else {
fmt.Fprintf(os.Stderr, "WARNING: Certificate %s will expire in %d days (on %s)\n", path, daysUntilExpiry, cert.NotAfter.Format("2006-01-02"))
}
}
}
type fileEntry struct {
name string
content []byte
}
type certWithPath struct {
cert *x509.Certificate
path string
}
// encodeCertsToFiles converts certificates to file entries based on encoding type
// If isSingle is true, certs are concatenated into a single file; otherwise one cert per file.
func encodeCertsToFiles(
certs []*x509.Certificate,
baseName string,
encoding string,
isSingle bool,
) ([]fileEntry, error) {
var files []fileEntry
switch encoding {
case "pem":
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
case "der":
if isSingle {
// For single file in DER, concatenate all cert DER bytes
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
// Individual DER file (should only have one cert)
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
case "both":
// Add PEM version
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
})
// Add DER version
if isSingle {
var derContent []byte
for _, cert := range certs {
derContent = append(derContent, cert.Raw...)
}
files = append(files, fileEntry{
name: baseName + ".crt",
content: derContent,
})
} else if len(certs) > 0 {
files = append(files, fileEntry{
name: baseName + ".crt",
content: certs[0].Raw,
})
}
default:
return nil, fmt.Errorf("unsupported encoding: %s (must be 'pem', 'der', or 'both')", encoding)
}
return files, nil
}
// encodeCertsToPEM encodes certificates to PEM format.
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent
}
func generateManifest(files []fileEntry) []byte {
var manifest strings.Builder
for _, file := range files {
if file.name == "MANIFEST" {
continue
}
hash := sha256.Sum256(file.content)
manifest.WriteString(fmt.Sprintf("%x %s\n", hash, file.name))
}
return []byte(manifest.String())
}
// closeWithErr attempts to close all provided closers, joining any close errors with baseErr.
func closeWithErr(baseErr error, closers ...io.Closer) error {
for _, c := range closers {
if c == nil {
continue
}
if cerr := c.Close(); cerr != nil {
baseErr = errors.Join(baseErr, cerr)
}
}
return baseErr
}
func createZipArchive(path string, files []fileEntry) error {
f, zerr := os.Create(path)
if zerr != nil {
return zerr
}
w := zip.NewWriter(f)
for _, file := range files {
fw, werr := w.Create(file.name)
if werr != nil {
return closeWithErr(werr, w, f)
}
if _, werr = fw.Write(file.content); werr != nil {
return closeWithErr(werr, w, f)
}
}
// Check errors on close operations
if cerr := w.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func createTarGzArchive(path string, files []fileEntry) error {
f, terr := os.Create(path)
if terr != nil {
return terr
}
gw := gzip.NewWriter(f)
tw := tar.NewWriter(gw)
for _, file := range files {
hdr := &tar.Header{
Name: file.name,
Mode: 0644,
Size: int64(len(file.content)),
}
if herr := tw.WriteHeader(hdr); herr != nil {
return closeWithErr(herr, tw, gw, f)
}
if _, werr := tw.Write(file.content); werr != nil {
return closeWithErr(werr, tw, gw, f)
}
}
// Check errors on close operations in the correct order
if cerr := tw.Close(); cerr != nil {
_ = gw.Close()
_ = f.Close()
return cerr
}
if cerr := gw.Close(); cerr != nil {
_ = f.Close()
return cerr
}
return f.Close()
}
func generateHashFile(path string, files []string) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
for _, file := range files {
data, rerr := os.ReadFile(file)
if rerr != nil {
return rerr
}
hash := sha256.Sum256(data)
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
}
return nil
}

View File

@@ -1,197 +0,0 @@
This project is an exploration into the utility of Jetbrains' Junie
to write smaller but tedious programs.
Task: build a certificate bundling tool in cmd/cert-bundler. It
creates archives of certificates chains.
A YAML file for this looks something like:
``` yaml
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: roots/core-ca.pem
intermediates:
- int/cca1.pem
- int/cca2.pem
- int/cca3.pem
- root: roots/ssh-ca.pem
intermediates:
- ssh/ssh_dmz1.pem
- ssh/ssh_internal.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
```
Some requirements:
1. First, all the certificates should be loaded.
2. For each root, each of the indivudal intermediates should be
checked to make sure they are properly signed by the root CA.
3. The program should optionally take an expiration period (defaulting
to one year), specified in config.expiration, and if any certificate
is within that expiration period, a warning should be printed.
4. If outputs.include_single is true, all certificates under chains
should be concatenated into a single file.
5. If outputs.include_individual is true, all certificates under
chains should be included at the root level (e.g. int/cca2.pem
would be cca2.pem in the archive).
6. If bundle.manifest is true, a "MANIFEST" file is created with
SHA256 sums of each file included in the archive.
7. For each of the formats, create an archive file in the output
directory (specified with `-o`) with that format.
- If zip is included, create a .zip file.
- If tgz is included, create a .tar.gz file with default compression
levels.
- All archive files should include any generated files (single
and/or individual) in the top-level directory.
8. In the output directory, create a file with the same name as
config.hashes that contains the SHA256 sum of all files created.
-----
The outputs.include_single and outputs.include_individual describe
what should go in the final archive. If both are specified, the output
archive should include both a single bundle.pem and each individual
certificate, for example.
-----
As it stands, given the following `bundle.yaml`:
``` yaml
config:
hashes: bundle.sha256
expiry: 1y
chains:
core_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: true
include_individual: false
manifest: true
formats:
- zip
- tgz
google_certs:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
outputs:
include_single: true
include_individual: false
manifest: true
formats:
- tgz
lets_encrypt:
certs:
- root: pems/isrg-root-x1.pem
intermediates:
- pems/le-e7.pem
outputs:
include_single: false
include_individual: true
manifest: false
formats:
- zip
```
The program outputs the following files:
- bundle.sha256
- core_certs_0.tgz (contains individual certs)
- core_certs_0.zip (contains individual certs)
- core_certs_1.tgz (contains core_certs.pem)
- core_certs_1.zip (contains core_certs.pem)
- google_certs_0.tgz
- lets_encrypt_0.zip
It should output
- bundle.sha256
- core_certs.tgz
- core_certs.zip
- google_certs.tgz
- lets_encrypt.zip
core_certs.* should contain `bundle.pem` and all the individual
certs. There should be no _$n$ variants of archives.
-----
Add an additional field to outputs: encoding. It should accept one of
`der`, `pem`, or `both`. If `der`, certificates should be output as a
`.crt` file containing a DER-encoded certificate. If `pem`, certificates
should be output as a `.pem` file containing a PEM-encoded certificate.
If both, both the `.crt` and `.pem` certificate should be included.
For example, given the previous config, if `encoding` is der, the
google_certs.tgz archive should contain
- bundle.crt
- MANIFEST
Or with lets_encrypt.zip:
- isrg-root-x1.crt
- le-e7.crt
However, if `encoding` is pem, the lets_encrypt.zip archive should contain:
- isrg-root-x1.pem
- le-e7.pem
And if it `encoding` is both, the lets_encrypt.zip archive should contain:
- isrg-root-x1.crt
- isrg-root-x1.pem
- le-e7.crt
- le-e7.pem
-----
The tgz format should output a `.tar.gz` file instead of a `.tgz` file.
-----
Move the format extensions to a global variable.
-----
Write a README.txt with a description of the bundle.yaml format.
Additionally, update the help text for the program (e.g. with `-h`)
to provide the same detailed information.
-----
It may be easier to embed the README.txt in the program on build.
-----
For the archive (tar.gz and zip) writers, make sure errors are
checked at the end, and don't just defer the close operations.

View File

@@ -2,6 +2,19 @@ config:
hashes: bundle.sha256
expiry: 1y
chains:
weird:
certs:
- root: pems/gts-r1.pem
intermediates:
- pems/goog-wr2.pem
- root: pems/isrg-root-x1.pem
outputs:
include_single: true
include_individual: true
manifest: true
formats:
- zip
- tgz
core_certs:
certs:
- root: pems/gts-r1.pem

View File

@@ -1,4 +0,0 @@
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

51
cmd/certser/main.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"crypto/x509"
"flag"
"fmt"
"strings"
"git.wntrmute.dev/kyle/goutils/certlib"
"git.wntrmute.dev/kyle/goutils/die"
"git.wntrmute.dev/kyle/goutils/lib"
)
const displayInt lib.HexEncodeMode = iota
func parseDisplayMode(mode string) lib.HexEncodeMode {
mode = strings.ToLower(mode)
if mode == "int" {
return displayInt
}
return lib.ParseHexEncodeMode(mode)
}
func serialString(cert *x509.Certificate, mode lib.HexEncodeMode) string {
if mode == displayInt {
return cert.SerialNumber.String()
}
return lib.HexEncode(cert.SerialNumber.Bytes(), mode)
}
func main() {
displayAs := flag.String("d", "int", "display mode (int, hex, uhex)")
showExpiry := flag.Bool("e", false, "show expiry date")
flag.Parse()
displayMode := parseDisplayMode(*displayAs)
for _, arg := range flag.Args() {
cert, err := certlib.LoadCertificate(arg)
die.If(err)
fmt.Printf("%s: %s", arg, serialString(cert, displayMode))
if *showExpiry {
fmt.Printf(" (%s)", cert.NotAfter.Format("2006-01-02"))
}
fmt.Println()
}
}

View File

@@ -2,9 +2,11 @@
package lib
import (
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@@ -109,3 +111,111 @@ func Duration(d time.Duration) string {
s += fmt.Sprintf("%dh%s", hours, d)
return s
}
type HexEncodeMode uint8
const (
// HexEncodeLower prints the bytes as lowercase hexadecimal.
HexEncodeLower HexEncodeMode = iota + 1
// HexEncodeUpper prints the bytes as uppercase hexadecimal.
HexEncodeUpper
// HexEncodeLowerColon prints the bytes as lowercase hexadecimal
// with colons between each pair of bytes.
HexEncodeLowerColon
// HexEncodeUpperColon prints the bytes as uppercase hexadecimal
// with colons between each pair of bytes.
HexEncodeUpperColon
)
func (m HexEncodeMode) String() string {
switch m {
case HexEncodeLower:
return "lower"
case HexEncodeUpper:
return "upper"
case HexEncodeLowerColon:
return "lcolon"
case HexEncodeUpperColon:
return "ucolon"
default:
panic("invalid hex encode mode")
}
}
func ParseHexEncodeMode(s string) HexEncodeMode {
switch strings.ToLower(s) {
case "lower":
return HexEncodeLower
case "upper":
return HexEncodeUpper
case "lcolon":
return HexEncodeLowerColon
case "ucolon":
return HexEncodeUpperColon
}
panic("invalid hex encode mode")
}
func hexColons(s string) string {
if len(s)%2 != 0 {
fmt.Fprintf(os.Stderr, "hex string: %s\n", s)
fmt.Fprintf(os.Stderr, "hex length: %d\n", len(s))
panic("invalid hex string length")
}
n := len(s)
if n <= 2 {
return s
}
pairCount := n / 2
if n%2 != 0 {
pairCount++
}
var b strings.Builder
b.Grow(n + pairCount - 1)
for i := 0; i < n; i += 2 {
b.WriteByte(s[i])
if i+1 < n {
b.WriteByte(s[i+1])
}
if i+2 < n {
b.WriteByte(':')
}
}
return b.String()
}
func hexEncode(b []byte) string {
s := hex.EncodeToString(b)
if len(s)%2 != 0 {
s = "0" + s
}
return s
}
// HexEncode encodes the given bytes as a hexadecimal string.
func HexEncode(b []byte, mode HexEncodeMode) string {
str := hexEncode(b)
switch mode {
case HexEncodeLower:
return str
case HexEncodeUpper:
return strings.ToUpper(str)
case HexEncodeLowerColon:
return hexColons(str)
case HexEncodeUpperColon:
return strings.ToUpper(hexColons(str))
default:
panic("invalid hex encode mode")
}
}

79
lib/lib_test.go Normal file
View File

@@ -0,0 +1,79 @@
package lib_test
import (
"testing"
"git.wntrmute.dev/kyle/goutils/lib"
)
func TestHexEncode_LowerUpper(t *testing.T) {
b := []byte{0x0f, 0xa1, 0x00, 0xff}
gotLower := lib.HexEncode(b, lib.HexEncodeLower)
if gotLower != "0fa100ff" {
t.Fatalf("lib.HexEncode lower: expected %q, got %q", "0fa100ff", gotLower)
}
gotUpper := lib.HexEncode(b, lib.HexEncodeUpper)
if gotUpper != "0FA100FF" {
t.Fatalf("lib.HexEncode upper: expected %q, got %q", "0FA100FF", gotUpper)
}
}
func TestHexEncode_ColonModes(t *testing.T) {
// Includes leading zero nibble and a zero byte to verify padding and separators
b := []byte{0x0f, 0xa1, 0x00, 0xff}
gotLColon := lib.HexEncode(b, lib.HexEncodeLowerColon)
if gotLColon != "0f:a1:00:ff" {
t.Fatalf("lib.HexEncode colon lower: expected %q, got %q", "0f:a1:00:ff", gotLColon)
}
gotUColon := lib.HexEncode(b, lib.HexEncodeUpperColon)
if gotUColon != "0F:A1:00:FF" {
t.Fatalf("lib.HexEncode colon upper: expected %q, got %q", "0F:A1:00:FF", gotUColon)
}
}
func TestHexEncode_EmptyInput(t *testing.T) {
var b []byte
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "" {
t.Fatalf("empty lower: expected empty string, got %q", got)
}
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "" {
t.Fatalf("empty upper: expected empty string, got %q", got)
}
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "" {
t.Fatalf("empty colon lower: expected empty string, got %q", got)
}
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "" {
t.Fatalf("empty colon upper: expected empty string, got %q", got)
}
}
func TestHexEncode_SingleByte(t *testing.T) {
b := []byte{0x0f}
if got := lib.HexEncode(b, lib.HexEncodeLower); got != "0f" {
t.Fatalf("single byte lower: expected %q, got %q", "0f", got)
}
if got := lib.HexEncode(b, lib.HexEncodeUpper); got != "0F" {
t.Fatalf("single byte upper: expected %q, got %q", "0F", got)
}
// For a single byte, colon modes should not introduce separators
if got := lib.HexEncode(b, lib.HexEncodeLowerColon); got != "0f" {
t.Fatalf("single byte colon lower: expected %q, got %q", "0f", got)
}
if got := lib.HexEncode(b, lib.HexEncodeUpperColon); got != "0F" {
t.Fatalf("single byte colon upper: expected %q, got %q", "0F", got)
}
}
func TestHexEncode_InvalidModePanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("expected panic for invalid mode, but function returned normally")
}
}()
// 0 is not a valid lib.HexEncodeMode (valid modes start at 1)
_ = lib.HexEncode([]byte{0x01}, lib.HexEncodeMode(0))
}