303 lines
8.4 KiB
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
|
|
}
|