clavitor/operations/pop-sync/ca/ca.go

303 lines
8.4 KiB
Go

// Package ca provides a minimal, self-contained private CA for mTLS between POPs.
//
// Uses only Go stdlib (crypto/x509, crypto/ecdsa, crypto/rand, encoding/pem).
// No external dependencies, no YubiKey/PKCS#11 support (just PEM files).
package ca
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"time"
)
// CA holds a root and intermediate certificate pair.
type CA struct {
RootCert *x509.Certificate
RootKey *ecdsa.PrivateKey
IntCert *x509.Certificate
IntKey *ecdsa.PrivateKey
}
// CertFiles holds paths to PEM-encoded certificate files.
type CertFiles struct {
RootKeyPath string
RootCertPath string
IntKeyPath string
IntCertPath string
}
// Default file paths for the CA hierarchy.
const (
DefaultRootKeyPath = "root-ca.key"
DefaultRootCertPath = "root-ca.crt"
DefaultIntKeyPath = "intermediate-ca.key"
DefaultIntCertPath = "intermediate-ca.crt"
)
// DefaultCertFiles returns the default file paths.
func DefaultCertFiles() CertFiles {
return CertFiles{
RootKeyPath: DefaultRootKeyPath,
RootCertPath: DefaultRootCertPath,
IntKeyPath: DefaultIntKeyPath,
IntCertPath: DefaultIntCertPath,
}
}
// Init creates a new root CA and intermediate CA with sensible defaults.
//
// Root CA: ECDSA P-256, 10 year validity, CA-only usage
// Intermediate CA: ECDSA P-256, 2 year validity, signed by root, CA-only usage
func Init() (*CA, error) {
return InitWithOptions(InitOptions{})
}
// InitOptions allows customization of the CA initialization.
type InitOptions struct {
RootSubject *pkix.Name
IntSubject *pkix.Name
RootValidity time.Duration
IntValidity time.Duration
RootKeyPath string
RootCertPath string
IntKeyPath string
IntCertPath string
}
// InitWithOptions creates a CA with custom options.
func InitWithOptions(opts InitOptions) (*CA, error) {
// Defaults
if opts.RootSubject == nil {
opts.RootSubject = &pkix.Name{
CommonName: "Clavitor Root CA",
Organization: []string{"Clavitor"},
OrganizationalUnit: []string{"Infrastructure"},
Country: []string{"US"},
}
}
if opts.IntSubject == nil {
opts.IntSubject = &pkix.Name{
CommonName: "Clavitor Intermediate CA",
Organization: []string{"Clavitor"},
OrganizationalUnit: []string{"POP Infrastructure"},
Country: []string{"US"},
}
}
if opts.RootValidity == 0 {
opts.RootValidity = 10 * 365 * 24 * time.Hour // 10 years
}
if opts.IntValidity == 0 {
opts.IntValidity = 2 * 365 * 24 * time.Hour // 2 years
}
if opts.RootKeyPath == "" {
opts.RootKeyPath = DefaultRootKeyPath
}
if opts.RootCertPath == "" {
opts.RootCertPath = DefaultRootCertPath
}
if opts.IntKeyPath == "" {
opts.IntKeyPath = DefaultIntKeyPath
}
if opts.IntCertPath == "" {
opts.IntCertPath = DefaultIntCertPath
}
// Generate root CA key
rootKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate root key: %w", err)
}
// Create root CA certificate
rootSerial, err := randSerial()
if err != nil {
return nil, fmt.Errorf("generate root serial: %w", err)
}
rootTemplate := &x509.Certificate{
SerialNumber: rootSerial,
Subject: *opts.RootSubject,
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(opts.RootValidity),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
// CA-only: no extended key usage for TLS (that would allow direct auth)
BasicConstraintsValid: true,
MaxPathLen: 1, // Can sign intermediates, but not another CA level below intermediate
}
rootCertDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, rootKey.Public(), rootKey)
if err != nil {
return nil, fmt.Errorf("create root cert: %w", err)
}
rootCert, err := x509.ParseCertificate(rootCertDER)
if err != nil {
return nil, fmt.Errorf("parse root cert: %w", err)
}
// Generate intermediate CA key
intKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate intermediate key: %w", err)
}
// Create intermediate CA certificate (signed by root)
intSerial, err := randSerial()
if err != nil {
return nil, fmt.Errorf("generate intermediate serial: %w", err)
}
intTemplate := &x509.Certificate{
SerialNumber: intSerial,
Subject: *opts.IntSubject,
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(opts.IntValidity),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
// CA-only: no extended key usage for TLS
BasicConstraintsValid: true,
MaxPathLen: 0, // Cannot sign other CAs, only end-entity certs
}
intCertDER, err := x509.CreateCertificate(rand.Reader, intTemplate, rootCert, intKey.Public(), rootKey)
if err != nil {
return nil, fmt.Errorf("create intermediate cert: %w", err)
}
intCert, err := x509.ParseCertificate(intCertDER)
if err != nil {
return nil, fmt.Errorf("parse intermediate cert: %w", err)
}
return &CA{
RootCert: rootCert,
RootKey: rootKey,
IntCert: intCert,
IntKey: intKey,
}, nil
}
// IssueOptions holds parameters for issuing a POP certificate.
type IssueOptions struct {
Region string // Region identifier (e.g., "use1", "sg1")
SANs []string // Hostnames and IPs
Validity time.Duration // Certificate validity (default 90 days)
Subject *pkix.Name // Optional custom subject (CN defaults to region)
}
// POPCertificate holds a generated POP certificate and its key.
type POPCertificate struct {
Cert *x509.Certificate
Key *ecdsa.PrivateKey
CertPEM []byte
KeyPEM []byte
ChainPEM []byte // Intermediate + Root bundled
}
// Issue generates a new POP certificate signed by the intermediate CA.
func (ca *CA) Issue(opts IssueOptions) (*POPCertificate, error) {
if opts.Region == "" {
return nil, fmt.Errorf("region is required")
}
if opts.Validity == 0 {
opts.Validity = 90 * 24 * time.Hour // 90 days default
}
if opts.Subject == nil {
opts.Subject = &pkix.Name{
CommonName: opts.Region,
Organization: []string{"Clavitor"},
OrganizationalUnit: []string{"POP"},
}
}
// Generate POP key
popKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate POP key: %w", err)
}
// Parse SANs into DNS names and IPs
var dnsNames []string
var ipAddrs []net.IP
for _, san := range opts.SANs {
if ip := net.ParseIP(san); ip != nil {
ipAddrs = append(ipAddrs, ip)
} else {
dnsNames = append(dnsNames, san)
}
}
// Ensure region is in DNS names if not already present
foundRegion := false
for _, name := range dnsNames {
if name == opts.Region {
foundRegion = true
break
}
}
if !foundRegion {
dnsNames = append([]string{opts.Region}, dnsNames...)
}
// Create POP certificate
serial, err := randSerial()
if err != nil {
return nil, fmt.Errorf("generate serial: %w", err)
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: *opts.Subject,
DNSNames: dnsNames,
IPAddresses: ipAddrs,
NotBefore: time.Now().Add(-5 * time.Minute),
NotAfter: time.Now().Add(opts.Validity),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, ca.IntCert, popKey.Public(), ca.IntKey)
if err != nil {
return nil, fmt.Errorf("create POP cert: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("parse POP cert: %w", err)
}
// Encode to PEM
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyBytes, _ := x509.MarshalECPrivateKey(popKey)
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
// Build chain (intermediate + root)
chainPEM := append(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.IntCert.Raw}), '\n')
chainPEM = append(chainPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.RootCert.Raw})...)
return &POPCertificate{
Cert: cert,
Key: popKey,
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
}, nil
}
// randSerial generates a random 128-bit serial number.
func randSerial() (*big.Int, error) {
serial := make([]byte, 16) // 128 bits
if _, err := rand.Read(serial); err != nil {
return nil, err
}
return new(big.Int).SetBytes(serial), nil
}