Files
goutils/certlib/certgen/config.go
Kyle Isom 5dbb46c3ee Add AIA fields (OCSPServer, IssuingCertificateURL) to certgen.Profile
The Profile struct now supports optional OCSPServer and
IssuingCertificateURL fields. When populated, these are set on the
x509.Certificate template as Authority Information Access extensions
before signing. Empty slices are omitted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 08:49:28 -07:00

277 lines
7.6 KiB
Go

package certgen
import (
"crypto"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"slices"
"strings"
"time"
"git.wntrmute.dev/kyle/goutils/lib"
)
type KeySpec struct {
Algorithm string `yaml:"algorithm"`
Size int `yaml:"size"`
}
func (ks KeySpec) String() string {
if strings.ToLower(ks.Algorithm) == nameEd25519 {
return nameEd25519
}
return fmt.Sprintf("%s-%d", ks.Algorithm, ks.Size)
}
func (ks KeySpec) Generate() (crypto.PublicKey, crypto.PrivateKey, error) {
switch strings.ToLower(ks.Algorithm) {
case "rsa":
return GenerateKey(x509.RSA, ks.Size)
case "ecdsa":
return GenerateKey(x509.ECDSA, ks.Size)
case nameEd25519:
return GenerateKey(x509.Ed25519, 0)
default:
return nil, nil, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
}
}
func (ks KeySpec) SigningAlgorithm() (x509.SignatureAlgorithm, error) {
switch strings.ToLower(ks.Algorithm) {
case "rsa":
return x509.SHA512WithRSAPSS, nil
case "ecdsa":
return x509.ECDSAWithSHA512, nil
case nameEd25519:
return x509.PureEd25519, nil
default:
return 0, fmt.Errorf("unknown key algorithm: %s", ks.Algorithm)
}
}
type Subject struct {
CommonName string `yaml:"common_name"`
Country string `yaml:"country"`
Locality string `yaml:"locality"`
Province string `yaml:"province"`
Organization string `yaml:"organization"`
OrganizationalUnit string `yaml:"organizational_unit"`
Email []string `yaml:"email"`
DNSNames []string `yaml:"dns"`
IPAddresses []string `yaml:"ips"`
}
type CertificateRequest struct {
KeySpec KeySpec `yaml:"key"`
Subject Subject `yaml:"subject"`
Profile Profile `yaml:"profile"`
}
func (cs CertificateRequest) Request(priv crypto.PrivateKey) (*x509.CertificateRequest, error) {
subject := pkix.Name{}
subject.CommonName = cs.Subject.CommonName
subject.Country = []string{cs.Subject.Country}
subject.Locality = []string{cs.Subject.Locality}
subject.Province = []string{cs.Subject.Province}
subject.Organization = []string{cs.Subject.Organization}
subject.OrganizationalUnit = []string{cs.Subject.OrganizationalUnit}
ipAddresses := make([]net.IP, 0, len(cs.Subject.IPAddresses))
for i, ip := range cs.Subject.IPAddresses {
ipAddresses = append(ipAddresses, net.ParseIP(ip))
if ipAddresses[i] == nil {
return nil, fmt.Errorf("invalid IP address: %s", ip)
}
}
dnsNames := cs.Subject.DNSNames
if isFQDN(cs.Subject.CommonName) && !slices.Contains(dnsNames, cs.Subject.CommonName) {
dnsNames = append(dnsNames, cs.Subject.CommonName)
}
req := &x509.CertificateRequest{
PublicKeyAlgorithm: 0,
PublicKey: getPublic(priv),
Subject: subject,
EmailAddresses: cs.Subject.Email,
DNSNames: dnsNames,
IPAddresses: ipAddresses,
}
reqBytes, err := x509.CreateCertificateRequest(rand.Reader, req, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate request: %w", err)
}
req, err = x509.ParseCertificateRequest(reqBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate request: %w", err)
}
return req, nil
}
func (cs CertificateRequest) Generate() (crypto.PrivateKey, *x509.CertificateRequest, error) {
_, priv, err := cs.KeySpec.Generate()
if err != nil {
return nil, nil, err
}
req, err := cs.Request(priv)
if err != nil {
return nil, nil, err
}
return priv, req, nil
}
type Profile struct {
IsCA bool `yaml:"is_ca"`
PathLen int `yaml:"path_len"`
KeyUse []string `yaml:"key_uses"`
ExtKeyUsages []string `yaml:"ext_key_usages"`
Expiry string `yaml:"expiry"`
OCSPServer []string `yaml:"ocsp_server,omitempty"`
IssuingCertificateURL []string `yaml:"issuing_certificate_url,omitempty"`
}
func (p Profile) templateFromRequest(req *x509.CertificateRequest) (*x509.Certificate, error) {
serial, err := SerialNumber()
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
expiry, err := lib.ParseDuration(p.Expiry)
if err != nil {
return nil, fmt.Errorf("parsing expiry: %w", err)
}
certTemplate := &x509.Certificate{
SignatureAlgorithm: req.SignatureAlgorithm,
PublicKeyAlgorithm: req.PublicKeyAlgorithm,
PublicKey: req.PublicKey,
SerialNumber: serial,
Subject: req.Subject,
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(expiry),
BasicConstraintsValid: true,
IsCA: p.IsCA,
MaxPathLen: p.PathLen,
DNSNames: req.DNSNames,
IPAddresses: req.IPAddresses,
}
for _, sku := range p.KeyUse {
ku, ok := keyUsageStrings[sku]
if !ok {
return nil, fmt.Errorf("invalid key usage: %s", p.KeyUse)
}
certTemplate.KeyUsage |= ku
}
for _, extKeyUsage := range p.ExtKeyUsages {
eku, ok := extKeyUsageStrings[extKeyUsage]
if !ok {
return nil, fmt.Errorf("invalid extended key usage: %s", extKeyUsage)
}
certTemplate.ExtKeyUsage = append(certTemplate.ExtKeyUsage, eku)
}
if len(p.OCSPServer) > 0 {
certTemplate.OCSPServer = p.OCSPServer
}
if len(p.IssuingCertificateURL) > 0 {
certTemplate.IssuingCertificateURL = p.IssuingCertificateURL
}
return certTemplate, nil
}
func (p Profile) SignRequest(
parent *x509.Certificate,
req *x509.CertificateRequest,
priv crypto.PrivateKey,
) (*x509.Certificate, error) {
tpl, err := p.templateFromRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to create certificate template: %w", err)
}
certBytes, err := x509.CreateCertificate(rand.Reader, tpl, parent, req.PublicKey, priv)
if err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err)
}
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse certificate: %w", err)
}
return cert, nil
}
func (p Profile) SelfSign(req *x509.CertificateRequest, priv crypto.PrivateKey) (*x509.Certificate, error) {
certTemplate, err := p.templateFromRequest(req)
if err != nil {
return nil, fmt.Errorf("failed to create certificate template: %w", err)
}
return p.SignRequest(certTemplate, req, priv)
}
// isFQDN returns true if s looks like a fully-qualified domain name.
func isFQDN(s string) bool {
if s == "" {
return false
}
// Must contain at least one dot and no spaces.
if !strings.Contains(s, ".") || strings.ContainsAny(s, " \t") {
return false
}
// Each label must be non-empty and consist of letters, digits, or hyphens.
for label := range strings.SplitSeq(strings.TrimSuffix(s, "."), ".") {
if label == "" {
return false
}
for _, c := range label {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') {
return false
}
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
}
return true
}
func SerialNumber() (*big.Int, error) {
serialNumberBytes := make([]byte, 20)
_, err := rand.Read(serialNumberBytes)
if err != nil {
return nil, fmt.Errorf("failed to generate serial number: %w", err)
}
return new(big.Int).SetBytes(serialNumberBytes), nil
}
// GenerateSelfSigned generates a self-signed certificate using the given certificate request.
func GenerateSelfSigned(creq *CertificateRequest) (*x509.Certificate, crypto.PrivateKey, error) {
priv, req, err := creq.Generate()
if err != nil {
return nil, nil, fmt.Errorf("failed to generate certificate request: %w", err)
}
cert, err := creq.Profile.SelfSign(req, priv)
if err != nil {
return nil, nil, fmt.Errorf("failed to self-sign certificate: %w", err)
}
return cert, priv, nil
}