Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d32a64dc0 | |||
| d70ca5ee87 | |||
| eca3a229a4 | |||
| 4c1eb03671 | |||
| f463eeed88 | |||
| 289c9d2343 |
87
.golangci.yml
Normal file
87
.golangci.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
build-tags: []
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- gofmt
|
||||
- goimports
|
||||
- misspell
|
||||
- unparam
|
||||
- unconvert
|
||||
- goconst
|
||||
- gocyclo
|
||||
- gosec
|
||||
- prealloc
|
||||
- copyloopvar
|
||||
- revive
|
||||
- typecheck
|
||||
|
||||
linters-settings:
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
goconst:
|
||||
min-len: 3
|
||||
min-occurrences: 3
|
||||
|
||||
misspell:
|
||||
locale: US
|
||||
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
disabled: false
|
||||
- name: error-return
|
||||
- name: error-naming
|
||||
- name: if-return
|
||||
- name: var-naming
|
||||
- name: package-comments
|
||||
disabled: true
|
||||
- name: indent-error-flow
|
||||
- name: context-as-argument
|
||||
|
||||
gosec:
|
||||
excludes:
|
||||
- G304 # File path from variable (common in file utilities)
|
||||
- G404 # Use of weak random (acceptable for non-crypto use)
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
# Exclude some linters from running on tests files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- errcheck
|
||||
- gosec
|
||||
|
||||
# Exclude embedded content from checks
|
||||
- path: ".*\\.txt$"
|
||||
linters:
|
||||
- all
|
||||
|
||||
# Ignore deprecation warnings in legacy code if needed
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA1019"
|
||||
|
||||
# Maximum issues count per one linter
|
||||
max-issues-per-linter: 0
|
||||
|
||||
# Maximum count of issues with the same text
|
||||
max-same-issues: 0
|
||||
|
||||
output:
|
||||
formats:
|
||||
- format: colored-line-number
|
||||
path: stdout
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
82
certlib/hosts/hosts.go
Normal file
82
certlib/hosts/hosts.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package hosts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
Host string
|
||||
Port int
|
||||
}
|
||||
|
||||
func (t *Target) String() string {
|
||||
return fmt.Sprintf("%s:%d", t.Host, t.Port)
|
||||
}
|
||||
|
||||
func parseURL(host string) (string, int, error) {
|
||||
url, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("certlib/hosts: invalid host: %s", host)
|
||||
}
|
||||
|
||||
if strings.ToLower(url.Scheme) != "https" {
|
||||
return "", 0, errors.New("certlib/hosts: only https scheme supported")
|
||||
}
|
||||
|
||||
if url.Port() == "" {
|
||||
return url.Hostname(), 443, nil
|
||||
}
|
||||
|
||||
port, err := strconv.ParseInt(url.Port(), 10, 16)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", url.Port())
|
||||
}
|
||||
|
||||
return url.Hostname(), int(port), nil
|
||||
}
|
||||
|
||||
func parseHostPort(host string) (string, int, error) {
|
||||
host, sport, err := net.SplitHostPort(host)
|
||||
if err == nil {
|
||||
port, err := strconv.ParseInt(sport, 10, 16)
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("certlib/hosts: invalid port: %s", sport)
|
||||
}
|
||||
|
||||
return host, int(port), nil
|
||||
}
|
||||
|
||||
return host, 443, nil
|
||||
}
|
||||
|
||||
func ParseHost(host string) (*Target, error) {
|
||||
host, port, err := parseURL(host)
|
||||
if err == nil {
|
||||
return &Target{Host: host, Port: port}, nil
|
||||
}
|
||||
|
||||
host, port, err = parseHostPort(host)
|
||||
if err == nil {
|
||||
return &Target{Host: host, Port: port}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("certlib/hosts: invalid host: %s", host)
|
||||
}
|
||||
|
||||
func ParseHosts(hosts ...string) ([]*Target, error) {
|
||||
targets := make([]*Target, 0, len(hosts))
|
||||
for _, host := range hosts {
|
||||
target, err := ParseHost(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targets = append(targets, target)
|
||||
}
|
||||
|
||||
return targets, nil
|
||||
}
|
||||
148
cmd/cert-bundler/README.txt
Normal file
148
cmd/cert-bundler/README.txt
Normal file
@@ -0,0 +1,148 @@
|
||||
cert-bundler: create certificate chain archives
|
||||
------------------------------------------------
|
||||
|
||||
Description
|
||||
cert-bundler creates archives of certificate chains from a YAML configuration
|
||||
file. It validates certificates, checks expiration dates, and generates
|
||||
archives in multiple formats (zip, tar.gz) with optional manifest files
|
||||
containing SHA256 checksums.
|
||||
|
||||
Usage
|
||||
cert-bundler [options]
|
||||
|
||||
Options:
|
||||
-c <file> Path to YAML configuration file (default: bundle.yaml)
|
||||
-o <dir> Output directory for archives (default: pkg)
|
||||
|
||||
YAML Configuration Format
|
||||
|
||||
The configuration file uses the following structure:
|
||||
|
||||
config:
|
||||
hashes: <filename>
|
||||
expiry: <duration>
|
||||
chains:
|
||||
<group_name>:
|
||||
certs:
|
||||
- root: <path>
|
||||
intermediates:
|
||||
- <path>
|
||||
- <path>
|
||||
- root: <path>
|
||||
intermediates:
|
||||
- <path>
|
||||
outputs:
|
||||
include_single: <bool>
|
||||
include_individual: <bool>
|
||||
manifest: <bool>
|
||||
encoding: <encoding>
|
||||
formats:
|
||||
- <format>
|
||||
- <format>
|
||||
|
||||
Configuration Fields
|
||||
|
||||
config:
|
||||
hashes: (optional) Name of the file to write SHA256 checksums of all
|
||||
generated archives. If omitted, no hash file is created.
|
||||
expiry: (optional) Expiration warning threshold. Certificates expiring
|
||||
within this period will trigger a warning. Supports formats like
|
||||
"1y" (year), "6m" (month), "30d" (day). Default: 1y
|
||||
|
||||
chains:
|
||||
Each key under "chains" defines a named certificate group. All certificates
|
||||
in a group are bundled together into archives with the group name.
|
||||
|
||||
certs:
|
||||
List of certificate chains. Each chain has:
|
||||
root: Path to root CA certificate (PEM or DER format)
|
||||
intermediates: List of paths to intermediate certificates
|
||||
|
||||
All intermediates are validated against their root CA. An error is
|
||||
reported if signature verification fails.
|
||||
|
||||
outputs:
|
||||
Defines output formats and content for the group's archives:
|
||||
|
||||
include_single: (bool) If true, all certificates in the group are
|
||||
concatenated into a single file named "bundle.pem"
|
||||
(or "bundle.crt" for DER encoding).
|
||||
|
||||
include_individual: (bool) If true, each certificate is included as
|
||||
a separate file in the archive, named after the
|
||||
original file (e.g., "int/cca2.pem" becomes
|
||||
"cca2.pem").
|
||||
|
||||
manifest: (bool) If true, a MANIFEST file is included containing
|
||||
SHA256 checksums of all files in the archive.
|
||||
|
||||
encoding: Specifies certificate encoding in the archive:
|
||||
- "pem": PEM format with .pem extension (default)
|
||||
- "der": DER format with .crt extension
|
||||
- "both": Both PEM and DER versions are included
|
||||
|
||||
formats: List of archive formats to generate:
|
||||
- "zip": Creates a .zip archive
|
||||
- "tgz": Creates a .tar.gz archive
|
||||
|
||||
Output Files
|
||||
|
||||
For each group and format combination, an archive is created:
|
||||
<group_name>.zip or <group_name>.tar.gz
|
||||
|
||||
If config.hashes is specified, a hash file is created in the output directory
|
||||
containing SHA256 checksums of all generated archives.
|
||||
|
||||
Example Configuration
|
||||
|
||||
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
|
||||
encoding: pem
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
|
||||
This configuration:
|
||||
- Creates core_certs.zip and core_certs.tar.gz in the output directory
|
||||
- Each archive contains bundle.pem (all certificates concatenated)
|
||||
- Each archive contains individual certificates (core-ca.pem, cca1.pem, etc.)
|
||||
- Each archive includes a MANIFEST file with SHA256 checksums
|
||||
- Creates bundle.sha256 with checksums of the two archives
|
||||
- Warns if any certificate expires within 1 year
|
||||
|
||||
Examples
|
||||
|
||||
# Create bundles using default configuration (bundle.yaml -> pkg/)
|
||||
cert-bundler
|
||||
|
||||
# Use custom configuration and output directory
|
||||
cert-bundler -c myconfig.yaml -o output
|
||||
|
||||
# Create bundles from testdata configuration
|
||||
cert-bundler -c testdata/bundle.yaml -o testdata/pkg
|
||||
|
||||
Notes
|
||||
- Certificate paths in the YAML are relative to the current working directory
|
||||
- All intermediates must be properly signed by their specified root CA
|
||||
- Certificates are checked for expiration; warnings are printed to stderr
|
||||
- Expired certificates do not prevent archive creation but generate warnings
|
||||
- Both PEM and DER certificate formats are supported as input
|
||||
- Archive filenames use the group name, not individual chain names
|
||||
- If both include_single and include_individual are true, archives contain both
|
||||
528
cmd/cert-bundler/main.go
Normal file
528
cmd/cert-bundler/main.go
Normal file
@@ -0,0 +1,528 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
_ "embed"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, readmeContent)
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = usage
|
||||
flag.StringVar(&configFile, "c", "bundle.yaml", "path to YAML configuration file")
|
||||
flag.StringVar(&outputDir, "o", "pkg", "output directory for archives")
|
||||
flag.Parse()
|
||||
|
||||
if configFile == "" {
|
||||
fmt.Fprintf(os.Stderr, "Error: configuration file required (-c flag)\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Load and parse configuration
|
||||
cfg, err := loadConfig(configFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %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
|
||||
if err := os.MkdirAll(outputDir, 0755); 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, err := processChainGroup(groupName, group, expiryDuration)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error processing chain group %s: %v\n", groupName, err)
|
||||
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 err := generateHashFile(hashFile, createdFiles); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating hash file: %v\n", err)
|
||||
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 err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 {
|
||||
// Load root certificate
|
||||
rootCert, err := certlib.LoadCertificate(chain.Root)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load root certificate %s: %v", chain.Root, err)
|
||||
}
|
||||
|
||||
// Check expiry for root
|
||||
checkExpiry(chain.Root, rootCert, expiryDuration)
|
||||
|
||||
// Add root to collections if needed
|
||||
if outputs.IncludeSingle {
|
||||
singleFileCerts = append(singleFileCerts, rootCert)
|
||||
}
|
||||
if outputs.IncludeIndividual {
|
||||
individualCerts = append(individualCerts, certWithPath{
|
||||
cert: rootCert,
|
||||
path: chain.Root,
|
||||
})
|
||||
}
|
||||
|
||||
// Load and validate intermediates
|
||||
for _, intPath := range chain.Intermediates {
|
||||
intCert, err := certlib.LoadCertificate(intPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %v", intPath, err)
|
||||
}
|
||||
|
||||
// Validate that intermediate is signed by root
|
||||
if err := intCert.CheckSignatureFrom(rootCert); err != nil {
|
||||
return nil, nil, fmt.Errorf("intermediate %s is not properly signed by root %s: %v", intPath, chain.Root, err)
|
||||
}
|
||||
|
||||
// Check expiry for intermediate
|
||||
checkExpiry(intPath, intCert, expiryDuration)
|
||||
|
||||
// Add intermediate to collections if needed
|
||||
if outputs.IncludeSingle {
|
||||
singleFileCerts = append(singleFileCerts, intCert)
|
||||
}
|
||||
if outputs.IncludeIndividual {
|
||||
individualCerts = append(individualCerts, certWithPath{
|
||||
cert: intCert,
|
||||
path: intPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return singleFileCerts, individualCerts, 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: %v", 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: %v", 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: %v", err)
|
||||
}
|
||||
case "tgz":
|
||||
if err := createTarGzArchive(archivePath, archiveFiles); err != nil {
|
||||
return nil, fmt.Errorf("failed to create tar.gz archive: %v", 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 {
|
||||
// Individual DER file (should only have one cert)
|
||||
if len(certs) > 0 {
|
||||
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())
|
||||
}
|
||||
|
||||
func createZipArchive(path string, files []fileEntry) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
|
||||
for _, file := range files {
|
||||
fw, err := w.Create(file.name)
|
||||
if err != nil {
|
||||
w.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := fw.Write(file.content); err != nil {
|
||||
w.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check errors on close operations
|
||||
if err := w.Close(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func createTarGzArchive(path string, files []fileEntry) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 err := tw.WriteHeader(hdr); err != nil {
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(file.content); err != nil {
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Check errors on close operations in the correct order
|
||||
if err := tw.Close(); err != nil {
|
||||
gw.Close()
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
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, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
fmt.Fprintf(f, "%x %s\n", hash, filepath.Base(file))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
194
cmd/cert-bundler/prompt.txt
Normal file
194
cmd/cert-bundler/prompt.txt
Normal file
@@ -0,0 +1,194 @@
|
||||
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.
|
||||
|
||||
|
||||
43
cmd/cert-bundler/testdata/bundle.yaml
vendored
Normal file
43
cmd/cert-bundler/testdata/bundle.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
core_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
- root: pems/isrg-root-x1.pem
|
||||
intermediates:
|
||||
- pems/le-e7.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
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
|
||||
encoding: der
|
||||
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
|
||||
encoding: both
|
||||
formats:
|
||||
- zip
|
||||
29
cmd/cert-bundler/testdata/pems/goog-wr2.pem
vendored
Normal file
29
cmd/cert-bundler/testdata/pems/goog-wr2.pem
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFCzCCAvOgAwIBAgIQf/AFoHxM3tEArZ1mpRB7mDANBgkqhkiG9w0BAQsFADBH
|
||||
MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM
|
||||
QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMjMxMjEzMDkwMDAwWhcNMjkwMjIw
|
||||
MTQwMDAwWjA7MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNl
|
||||
cnZpY2VzMQwwCgYDVQQDEwNXUjIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
|
||||
AoIBAQCp/5x/RR5wqFOfytnlDd5GV1d9vI+aWqxG8YSau5HbyfsvAfuSCQAWXqAc
|
||||
+MGr+XgvSszYhaLYWTwO0xj7sfUkDSbutltkdnwUxy96zqhMt/TZCPzfhyM1IKji
|
||||
aeKMTj+xWfpgoh6zySBTGYLKNlNtYE3pAJH8do1cCA8Kwtzxc2vFE24KT3rC8gIc
|
||||
LrRjg9ox9i11MLL7q8Ju26nADrn5Z9TDJVd06wW06Y613ijNzHoU5HEDy01hLmFX
|
||||
xRmpC5iEGuh5KdmyjS//V2pm4M6rlagplmNwEmceOuHbsCFx13ye/aoXbv4r+zgX
|
||||
FNFmp6+atXDMyGOBOozAKql2N87jAgMBAAGjgf4wgfswDgYDVR0PAQH/BAQDAgGG
|
||||
MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/
|
||||
AgEAMB0GA1UdDgQWBBTeGx7teRXUPjckwyG77DQ5bUKyMDAfBgNVHSMEGDAWgBTk
|
||||
rysmcRorSCeFL1JmLO/wiRNxPjA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAKG
|
||||
GGh0dHA6Ly9pLnBraS5nb29nL3IxLmNydDArBgNVHR8EJDAiMCCgHqAchhpodHRw
|
||||
Oi8vYy5wa2kuZ29vZy9yL3IxLmNybDATBgNVHSAEDDAKMAgGBmeBDAECATANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEARXWL5R87RBOWGqtY8TXJbz3S0DNKhjO6V1FP7sQ02hYS
|
||||
TL8Tnw3UVOlIecAwPJQl8hr0ujKUtjNyC4XuCRElNJThb0Lbgpt7fyqaqf9/qdLe
|
||||
SiDLs/sDA7j4BwXaWZIvGEaYzq9yviQmsR4ATb0IrZNBRAq7x9UBhb+TV+PfdBJT
|
||||
DhEl05vc3ssnbrPCuTNiOcLgNeFbpwkuGcuRKnZc8d/KI4RApW//mkHgte8y0YWu
|
||||
ryUJ8GLFbsLIbjL9uNrizkqRSvOFVU6xddZIMy9vhNkSXJ/UcZhjJY1pXAprffJB
|
||||
vei7j+Qi151lRehMCofa6WBmiA4fx+FOVsV2/7R6V2nyAiIJJkEd2nSi5SnzxJrl
|
||||
Xdaqev3htytmOPvoKWa676ATL/hzfvDaQBEcXd2Ppvy+275W+DKcH0FBbX62xevG
|
||||
iza3F4ydzxl6NJ8hk8R+dDXSqv1MbRT1ybB5W0k8878XSOjvmiYTDIfyc9acxVJr
|
||||
Y/cykHipa+te1pOhv7wYPYtZ9orGBV5SGOJm4NrB3K1aJar0RfzxC3ikr7Dyc6Qw
|
||||
qDTBU39CluVIQeuQRgwG3MuSxl7zRERDRilGoKb8uY45JzmxWuKxrfwT/478JuHU
|
||||
/oTxUFqOl2stKnn7QGTq8z29W+GgBLCXSBxC9epaHM0myFH/FJlniXJfHeytWt0=
|
||||
-----END CERTIFICATE-----
|
||||
31
cmd/cert-bundler/testdata/pems/gts-r1.pem
vendored
Normal file
31
cmd/cert-bundler/testdata/pems/gts-r1.pem
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c
|
||||
-----END CERTIFICATE-----
|
||||
31
cmd/cert-bundler/testdata/pems/isrg-root-x1.pem
vendored
Normal file
31
cmd/cert-bundler/testdata/pems/isrg-root-x1.pem
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
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
|
||||
-----END CERTIFICATE-----
|
||||
26
cmd/cert-bundler/testdata/pems/le-e7.pem
vendored
Normal file
26
cmd/cert-bundler/testdata/pems/le-e7.pem
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEVzCCAj+gAwIBAgIRAKp18eYrjwoiCWbTi7/UuqEwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjQwMzEzMDAwMDAw
|
||||
WhcNMjcwMzEyMjM1OTU5WjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
|
||||
RW5jcnlwdDELMAkGA1UEAxMCRTcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARB6AST
|
||||
CFh/vjcwDMCgQer+VtqEkz7JANurZxLP+U9TCeioL6sp5Z8VRvRbYk4P1INBmbef
|
||||
QHJFHCxcSjKmwtvGBWpl/9ra8HW0QDsUaJW2qOJqceJ0ZVFT3hbUHifBM/2jgfgw
|
||||
gfUwDgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcD
|
||||
ATASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBSuSJ7chx1EoG/aouVgdAR4
|
||||
wpwAgDAfBgNVHSMEGDAWgBR5tFnme7bl5AFzgAiIyBpY9umbbjAyBggrBgEFBQcB
|
||||
AQQmMCQwIgYIKwYBBQUHMAKGFmh0dHA6Ly94MS5pLmxlbmNyLm9yZy8wEwYDVR0g
|
||||
BAwwCjAIBgZngQwBAgEwJwYDVR0fBCAwHjAcoBqgGIYWaHR0cDovL3gxLmMubGVu
|
||||
Y3Iub3JnLzANBgkqhkiG9w0BAQsFAAOCAgEAjx66fDdLk5ywFn3CzA1w1qfylHUD
|
||||
aEf0QZpXcJseddJGSfbUUOvbNR9N/QQ16K1lXl4VFyhmGXDT5Kdfcr0RvIIVrNxF
|
||||
h4lqHtRRCP6RBRstqbZ2zURgqakn/Xip0iaQL0IdfHBZr396FgknniRYFckKORPG
|
||||
yM3QKnd66gtMst8I5nkRQlAg/Jb+Gc3egIvuGKWboE1G89NTsN9LTDD3PLj0dUMr
|
||||
OIuqVjLB8pEC6yk9enrlrqjXQgkLEYhXzq7dLafv5Vkig6Gl0nuuqjqfp0Q1bi1o
|
||||
yVNAlXe6aUXw92CcghC9bNsKEO1+M52YY5+ofIXlS/SEQbvVYYBLZ5yeiglV6t3S
|
||||
M6H+vTG0aP9YHzLn/KVOHzGQfXDP7qM5tkf+7diZe7o2fw6O7IvN6fsQXEQQj8TJ
|
||||
UXJxv2/uJhcuy/tSDgXwHM8Uk34WNbRT7zGTGkQRX0gsbjAea/jYAoWv0ZvQRwpq
|
||||
Pe79D/i7Cep8qWnA+7AE/3B3S/3dEEYmc0lpe1366A/6GEgk3ktr9PEoQrLChs6I
|
||||
tu3wnNLB2euC8IKGLQFpGtOO/2/hiAKjyajaBP25w1jF0Wl8Bbqne3uZ2q1GyPFJ
|
||||
YRmT7/OXpmOH/FVLtwS+8ng1cAmpCujPwteJZNcDG0sF2n/sc0+SQf49fdyUK0ty
|
||||
+VUwFj9tmWxyR/M=
|
||||
-----END CERTIFICATE-----
|
||||
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
Normal file
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
|
||||
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
|
||||
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
|
||||
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip
|
||||
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
Normal file
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
Normal file
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
Normal file
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
Normal file
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
Normal file
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
Normal file
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
Normal file
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
Normal file
Binary file not shown.
36
cmd/cert-revcheck/README.txt
Normal file
36
cmd/cert-revcheck/README.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
cert-revcheck: check certificate expiry and revocation
|
||||
-----------------------------------------------------
|
||||
|
||||
Description
|
||||
cert-revcheck accepts a list of certificate files (PEM or DER) or
|
||||
site addresses (host[:port]) and checks whether the leaf certificate
|
||||
is expired or revoked. Revocation checks use CRL and OCSP via the
|
||||
certlib/revoke package.
|
||||
|
||||
Usage
|
||||
cert-revcheck [options] <target> [<target>...]
|
||||
|
||||
Options
|
||||
-hardfail treat revocation check failures as fatal (default: false)
|
||||
-timeout dur HTTP/OCSP/CRL timeout for network operations (default: 10s)
|
||||
-v verbose output
|
||||
|
||||
Targets
|
||||
- File paths to certificates in PEM or DER format.
|
||||
- Site addresses in the form host or host:port. If no port is
|
||||
provided, 443 is assumed.
|
||||
|
||||
Examples
|
||||
# Check a PEM file
|
||||
cert-revcheck ./server.pem
|
||||
|
||||
# Check a DER (single) certificate
|
||||
cert-revcheck ./server.der
|
||||
|
||||
# Check a live site (leaf certificate)
|
||||
cert-revcheck example.com:443
|
||||
|
||||
Notes
|
||||
- For sites, only the leaf certificate is checked.
|
||||
- When -hardfail is set, network issues during OCSP/CRL fetch will
|
||||
cause the check to fail (treated as revoked).
|
||||
139
cmd/cert-revcheck/main.go
Normal file
139
cmd/cert-revcheck/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts"
|
||||
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
|
||||
"git.wntrmute.dev/kyle/goutils/fileutil"
|
||||
)
|
||||
|
||||
var (
|
||||
hardfail bool
|
||||
timeout time.Duration
|
||||
verbose bool
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.BoolVar(&hardfail, "hardfail", false, "treat revocation check failures as fatal")
|
||||
flag.DurationVar(&timeout, "timeout", 10*time.Second, "network timeout for OCSP/CRL fetches and TLS site connects")
|
||||
flag.BoolVar(&verbose, "v", false, "verbose output")
|
||||
flag.Parse()
|
||||
|
||||
revoke.HardFail = hardfail
|
||||
// Set HTTP client timeout for revocation library
|
||||
revoke.HTTPClient.Timeout = timeout
|
||||
|
||||
if flag.NArg() == 0 {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s [options] <target> [<target>...]\n", os.Args[0])
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
exitCode := 0
|
||||
for _, target := range flag.Args() {
|
||||
status, err := processTarget(target)
|
||||
switch status {
|
||||
case "OK":
|
||||
fmt.Printf("%s: OK\n", target)
|
||||
case "EXPIRED":
|
||||
fmt.Printf("%s: EXPIRED: %v\n", target, err)
|
||||
exitCode = 1
|
||||
case "REVOKED":
|
||||
fmt.Printf("%s: REVOKED\n", target)
|
||||
exitCode = 1
|
||||
case "UNKNOWN":
|
||||
fmt.Printf("%s: UNKNOWN: %v\n", target, err)
|
||||
if hardfail {
|
||||
// In hardfail, treat unknown as failure
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func processTarget(target string) (string, error) {
|
||||
if fileutil.FileDoesExist(target) {
|
||||
return checkFile(target)
|
||||
}
|
||||
|
||||
// Not a file; treat as site
|
||||
return checkSite(target)
|
||||
}
|
||||
|
||||
func checkFile(path string) (string, error) {
|
||||
in, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return "UNKNOWN", err
|
||||
}
|
||||
|
||||
// Try PEM first; if that fails, try single DER cert
|
||||
certs, err := certlib.ReadCertificates(in)
|
||||
if err != nil || len(certs) == 0 {
|
||||
cert, _, derr := certlib.ReadCertificate(in)
|
||||
if derr != nil || cert == nil {
|
||||
if err == nil {
|
||||
err = derr
|
||||
}
|
||||
return "UNKNOWN", err
|
||||
}
|
||||
return evaluateCert(cert)
|
||||
}
|
||||
|
||||
// Evaluate the first certificate (leaf) by default
|
||||
return evaluateCert(certs[0])
|
||||
}
|
||||
|
||||
func checkSite(hostport string) (string, error) {
|
||||
// Use certlib/hosts to parse host/port (supports https URLs and host:port)
|
||||
target, err := hosts.ParseHost(hostport)
|
||||
if err != nil {
|
||||
return "UNKNOWN", err
|
||||
}
|
||||
|
||||
d := &net.Dialer{Timeout: timeout}
|
||||
conn, err := tls.DialWithDialer(d, "tcp", target.String(), &tls.Config{InsecureSkipVerify: true, ServerName: target.Host})
|
||||
if err != nil {
|
||||
return "UNKNOWN", err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
state := conn.ConnectionState()
|
||||
if len(state.PeerCertificates) == 0 {
|
||||
return "UNKNOWN", fmt.Errorf("no peer certificates presented")
|
||||
}
|
||||
return evaluateCert(state.PeerCertificates[0])
|
||||
}
|
||||
|
||||
func evaluateCert(cert *x509.Certificate) (string, error) {
|
||||
// Expiry check
|
||||
now := time.Now()
|
||||
if !now.Before(cert.NotAfter) {
|
||||
return "EXPIRED", fmt.Errorf("expired at %s", cert.NotAfter)
|
||||
}
|
||||
if !now.After(cert.NotBefore) {
|
||||
return "EXPIRED", fmt.Errorf("not valid until %s", cert.NotBefore)
|
||||
}
|
||||
|
||||
// Revocation check using certlib/revoke
|
||||
revoked, ok, err := revoke.VerifyCertificateError(cert)
|
||||
if revoked {
|
||||
// If revoked is true, ok will be true per implementation, err may describe why
|
||||
return "REVOKED", err
|
||||
}
|
||||
if !ok {
|
||||
// Revocation status could not be determined
|
||||
return "UNKNOWN", err
|
||||
}
|
||||
|
||||
return "OK", nil
|
||||
}
|
||||
@@ -64,11 +64,7 @@ func LoadFile(path string) error {
|
||||
addLine(line)
|
||||
}
|
||||
|
||||
if err = scanner.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// LoadFileFor scans the ini file at path, loading the default section
|
||||
|
||||
@@ -42,69 +42,96 @@ func ParseReader(r io.Reader) (cfg ConfigMap, err error) {
|
||||
line string
|
||||
longLine bool
|
||||
currentSection string
|
||||
lineBytes []byte
|
||||
isPrefix bool
|
||||
)
|
||||
|
||||
for {
|
||||
err = nil
|
||||
lineBytes, isPrefix, err = buf.ReadLine()
|
||||
if io.EOF == err {
|
||||
line, longLine, err = readConfigLine(buf, line, longLine)
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
break
|
||||
} else if err != nil {
|
||||
break
|
||||
} else if isPrefix {
|
||||
line += string(lineBytes)
|
||||
|
||||
longLine = true
|
||||
continue
|
||||
} else if longLine {
|
||||
line += string(lineBytes)
|
||||
longLine = false
|
||||
} else {
|
||||
line = string(lineBytes)
|
||||
}
|
||||
|
||||
if commentLine.MatchString(line) {
|
||||
if line == "" {
|
||||
continue
|
||||
} else if blankLine.MatchString(line) {
|
||||
continue
|
||||
} else if configSection.MatchString(line) {
|
||||
section := configSection.ReplaceAllString(line,
|
||||
"$1")
|
||||
if section == "" {
|
||||
err = fmt.Errorf("invalid structure in file")
|
||||
break
|
||||
} else if !cfg.SectionInConfig(section) {
|
||||
cfg[section] = make(map[string]string, 0)
|
||||
}
|
||||
currentSection = section
|
||||
} else if configLine.MatchString(line) {
|
||||
regex := configLine
|
||||
if quotedConfigLine.MatchString(line) {
|
||||
regex = quotedConfigLine
|
||||
}
|
||||
if currentSection == "" {
|
||||
currentSection = DefaultSection
|
||||
if !cfg.SectionInConfig(currentSection) {
|
||||
cfg[currentSection] = map[string]string{}
|
||||
}
|
||||
}
|
||||
key := regex.ReplaceAllString(line, "$1")
|
||||
val := regex.ReplaceAllString(line, "$2")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
cfg[currentSection][key] = val
|
||||
} else {
|
||||
err = fmt.Errorf("invalid config file")
|
||||
}
|
||||
|
||||
currentSection, err = processConfigLine(cfg, line, currentSection)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// readConfigLine reads and assembles a complete configuration line, handling long lines.
|
||||
func readConfigLine(buf *bufio.Reader, currentLine string, longLine bool) (line string, stillLong bool, err error) {
|
||||
lineBytes, isPrefix, err := buf.ReadLine()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
if isPrefix {
|
||||
return currentLine + string(lineBytes), true, nil
|
||||
} else if longLine {
|
||||
return currentLine + string(lineBytes), false, nil
|
||||
}
|
||||
return string(lineBytes), false, nil
|
||||
}
|
||||
|
||||
// processConfigLine processes a single line and updates the configuration map.
|
||||
func processConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
|
||||
if commentLine.MatchString(line) || blankLine.MatchString(line) {
|
||||
return currentSection, nil
|
||||
}
|
||||
|
||||
if configSection.MatchString(line) {
|
||||
return handleSectionLine(cfg, line)
|
||||
}
|
||||
|
||||
if configLine.MatchString(line) {
|
||||
return handleConfigLine(cfg, line, currentSection)
|
||||
}
|
||||
|
||||
return currentSection, fmt.Errorf("invalid config file")
|
||||
}
|
||||
|
||||
// handleSectionLine processes a section header line.
|
||||
func handleSectionLine(cfg ConfigMap, line string) (string, error) {
|
||||
section := configSection.ReplaceAllString(line, "$1")
|
||||
if section == "" {
|
||||
return "", fmt.Errorf("invalid structure in file")
|
||||
}
|
||||
if !cfg.SectionInConfig(section) {
|
||||
cfg[section] = make(map[string]string, 0)
|
||||
}
|
||||
return section, nil
|
||||
}
|
||||
|
||||
// handleConfigLine processes a key=value configuration line.
|
||||
func handleConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
|
||||
regex := configLine
|
||||
if quotedConfigLine.MatchString(line) {
|
||||
regex = quotedConfigLine
|
||||
}
|
||||
|
||||
if currentSection == "" {
|
||||
currentSection = DefaultSection
|
||||
if !cfg.SectionInConfig(currentSection) {
|
||||
cfg[currentSection] = map[string]string{}
|
||||
}
|
||||
}
|
||||
|
||||
key := regex.ReplaceAllString(line, "$1")
|
||||
val := regex.ReplaceAllString(line, "$2")
|
||||
if key != "" {
|
||||
cfg[currentSection][key] = val
|
||||
}
|
||||
|
||||
return currentSection, nil
|
||||
}
|
||||
|
||||
// SectionInConfig determines whether a section is in the configuration.
|
||||
func (c ConfigMap) SectionInConfig(section string) bool {
|
||||
_, ok := c[section]
|
||||
|
||||
Reference in New Issue
Block a user