|
| 1 | +package cert |
| 2 | + |
| 3 | +import ( |
| 4 | + "crypto/ecdsa" |
| 5 | + "crypto/elliptic" |
| 6 | + "crypto/rand" |
| 7 | + "crypto/x509" |
| 8 | + "crypto/x509/pkix" |
| 9 | + "encoding/pem" |
| 10 | + "fmt" |
| 11 | + "math/big" |
| 12 | + "net" |
| 13 | + "time" |
| 14 | +) |
| 15 | + |
| 16 | +const ( |
| 17 | + // Organization is the default organization name used in generated certificates. |
| 18 | + Organization = "Multigres Operator" |
| 19 | + // CAValidityDuration is the duration the CA certificate is valid for (10 years). |
| 20 | + CAValidityDuration = 10 * 365 * 24 * time.Hour |
| 21 | + // ServerValidityDuration is the duration the server certificate is valid for (1 year). |
| 22 | + ServerValidityDuration = 365 * 24 * time.Hour |
| 23 | +) |
| 24 | + |
| 25 | +// CAArtifacts holds the Certificate Authority keys and PEM-encoded data. |
| 26 | +type CAArtifacts struct { |
| 27 | + Cert *x509.Certificate |
| 28 | + Key *ecdsa.PrivateKey |
| 29 | + CertPEM []byte |
| 30 | + KeyPEM []byte |
| 31 | +} |
| 32 | + |
| 33 | +// ServerArtifacts holds the server certificate PEM-encoded data. |
| 34 | +type ServerArtifacts struct { |
| 35 | + CertPEM []byte |
| 36 | + KeyPEM []byte |
| 37 | +} |
| 38 | + |
| 39 | +// serverCertConfig holds the resolved configuration for server cert generation. |
| 40 | +type serverCertConfig struct { |
| 41 | + extKeyUsages []x509.ExtKeyUsage |
| 42 | +} |
| 43 | + |
| 44 | +// ServerCertOption configures optional behavior for GenerateServerCert. |
| 45 | +type ServerCertOption func(*serverCertConfig) |
| 46 | + |
| 47 | +// WithExtKeyUsages overrides the default extended key usage for the generated |
| 48 | +// server certificate. The default is [x509.ExtKeyUsageServerAuth]. |
| 49 | +// Use this when mutual TLS is needed (e.g., pgBackRest requires both |
| 50 | +// ServerAuth and ClientAuth). |
| 51 | +func WithExtKeyUsages(usages ...x509.ExtKeyUsage) ServerCertOption { |
| 52 | + return func(cfg *serverCertConfig) { |
| 53 | + cfg.extKeyUsages = usages |
| 54 | + } |
| 55 | +} |
| 56 | + |
| 57 | +// internal variables for mocking in tests |
| 58 | +var ( |
| 59 | + marshalECPrivateKey = x509.MarshalECPrivateKey |
| 60 | + parseCertificate = x509.ParseCertificate |
| 61 | +) |
| 62 | + |
| 63 | +// GenerateCA creates a new self-signed Root CA using ECDSA P-256. |
| 64 | +func GenerateCA() (*CAArtifacts, error) { |
| 65 | + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 66 | + if err != nil { |
| 67 | + return nil, fmt.Errorf("failed to generate CA private key: %w", err) |
| 68 | + } |
| 69 | + |
| 70 | + template := x509.Certificate{ |
| 71 | + SerialNumber: big.NewInt(1), |
| 72 | + Subject: pkix.Name{ |
| 73 | + CommonName: "Multigres Operator CA", |
| 74 | + Organization: []string{Organization}, |
| 75 | + }, |
| 76 | + NotBefore: time.Now().Add(-1 * time.Hour), |
| 77 | + NotAfter: time.Now().Add(CAValidityDuration), |
| 78 | + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, |
| 79 | + ExtKeyUsage: []x509.ExtKeyUsage{ |
| 80 | + x509.ExtKeyUsageServerAuth, |
| 81 | + x509.ExtKeyUsageClientAuth, |
| 82 | + }, |
| 83 | + BasicConstraintsValid: true, |
| 84 | + IsCA: true, |
| 85 | + } |
| 86 | + |
| 87 | + derBytes, err := x509.CreateCertificate( |
| 88 | + rand.Reader, |
| 89 | + &template, |
| 90 | + &template, |
| 91 | + &privKey.PublicKey, |
| 92 | + privKey, |
| 93 | + ) |
| 94 | + if err != nil { |
| 95 | + return nil, fmt.Errorf("failed to create CA certificate: %w", err) |
| 96 | + } |
| 97 | + |
| 98 | + caCert, err := parseCertificate(derBytes) |
| 99 | + if err != nil { |
| 100 | + return nil, fmt.Errorf("failed to parse generated CA: %w", err) |
| 101 | + } |
| 102 | + |
| 103 | + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) |
| 104 | + |
| 105 | + keyBytes, err := marshalECPrivateKey(privKey) |
| 106 | + if err != nil { |
| 107 | + return nil, fmt.Errorf("failed to marshal CA key: %w", err) |
| 108 | + } |
| 109 | + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) |
| 110 | + |
| 111 | + return &CAArtifacts{ |
| 112 | + Cert: caCert, |
| 113 | + Key: privKey, |
| 114 | + CertPEM: certPEM, |
| 115 | + KeyPEM: keyPEM, |
| 116 | + }, nil |
| 117 | +} |
| 118 | + |
| 119 | +// GenerateServerCert creates a leaf certificate signed by the provided CA. |
| 120 | +// By default, the certificate includes only x509.ExtKeyUsageServerAuth. |
| 121 | +// Use WithExtKeyUsages to override this (e.g., for mutual TLS). |
| 122 | +func GenerateServerCert( |
| 123 | + ca *CAArtifacts, |
| 124 | + commonName string, |
| 125 | + dnsNames []string, |
| 126 | + opts ...ServerCertOption, |
| 127 | +) (*ServerArtifacts, error) { |
| 128 | + if ca == nil { |
| 129 | + return nil, fmt.Errorf("CA artifacts cannot be nil") |
| 130 | + } |
| 131 | + |
| 132 | + cfg := serverCertConfig{ |
| 133 | + extKeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, |
| 134 | + } |
| 135 | + for _, opt := range opts { |
| 136 | + opt(&cfg) |
| 137 | + } |
| 138 | + |
| 139 | + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 140 | + if err != nil { |
| 141 | + return nil, fmt.Errorf("failed to generate server private key: %w", err) |
| 142 | + } |
| 143 | + |
| 144 | + // Serial number should be unique. In a real PKI we'd track this, |
| 145 | + // but for ephemeral K8s secrets using a large random int is standard practice. |
| 146 | + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) |
| 147 | + serialNumber, _ := rand.Int(rand.Reader, serialNumberLimit) |
| 148 | + |
| 149 | + template := x509.Certificate{ |
| 150 | + SerialNumber: serialNumber, |
| 151 | + Subject: pkix.Name{ |
| 152 | + CommonName: commonName, |
| 153 | + Organization: []string{Organization}, |
| 154 | + }, |
| 155 | + DNSNames: dnsNames, |
| 156 | + NotBefore: time.Now().Add(-1 * time.Hour), |
| 157 | + NotAfter: time.Now().Add(ServerValidityDuration), |
| 158 | + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, |
| 159 | + ExtKeyUsage: cfg.extKeyUsages, |
| 160 | + } |
| 161 | + |
| 162 | + if ip := net.ParseIP(commonName); ip != nil { |
| 163 | + template.IPAddresses = append(template.IPAddresses, ip) |
| 164 | + } |
| 165 | + |
| 166 | + derBytes, err := x509.CreateCertificate( |
| 167 | + rand.Reader, |
| 168 | + &template, |
| 169 | + ca.Cert, |
| 170 | + &privKey.PublicKey, |
| 171 | + ca.Key, |
| 172 | + ) |
| 173 | + if err != nil { |
| 174 | + return nil, fmt.Errorf("failed to sign server certificate: %w", err) |
| 175 | + } |
| 176 | + |
| 177 | + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) |
| 178 | + |
| 179 | + keyBytes, err := marshalECPrivateKey(privKey) |
| 180 | + if err != nil { |
| 181 | + return nil, fmt.Errorf("failed to marshal server key: %w", err) |
| 182 | + } |
| 183 | + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes}) |
| 184 | + |
| 185 | + return &ServerArtifacts{ |
| 186 | + CertPEM: certPEM, |
| 187 | + KeyPEM: keyPEM, |
| 188 | + }, nil |
| 189 | +} |
| 190 | + |
| 191 | +// ParseCA decodes PEM data back into crypto objects for signing usage. |
| 192 | +func ParseCA(certPEM, keyPEM []byte) (*CAArtifacts, error) { |
| 193 | + // Parse Cert |
| 194 | + block, _ := pem.Decode(certPEM) |
| 195 | + if block == nil { |
| 196 | + return nil, fmt.Errorf("failed to decode CA cert PEM") |
| 197 | + } |
| 198 | + cert, err := x509.ParseCertificate(block.Bytes) |
| 199 | + if err != nil { |
| 200 | + return nil, fmt.Errorf("failed to parse CA cert: %w", err) |
| 201 | + } |
| 202 | + |
| 203 | + // Parse Key |
| 204 | + block, _ = pem.Decode(keyPEM) |
| 205 | + if block == nil { |
| 206 | + return nil, fmt.Errorf("failed to decode CA key PEM") |
| 207 | + } |
| 208 | + // We optimistically try EC, then fallback to PKCS8 if needed, strictly P-256 for us. |
| 209 | + key, err := x509.ParseECPrivateKey(block.Bytes) |
| 210 | + if err != nil { |
| 211 | + // Fallback for older keys or PKCS8 wrapping |
| 212 | + if k, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { |
| 213 | + switch k := k.(type) { |
| 214 | + case *ecdsa.PrivateKey: |
| 215 | + key = k |
| 216 | + default: |
| 217 | + return nil, fmt.Errorf("found non-ECDSA private key type in CA secret") |
| 218 | + } |
| 219 | + } else { |
| 220 | + return nil, fmt.Errorf("failed to parse CA private key: %w", err) |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + return &CAArtifacts{ |
| 225 | + Cert: cert, |
| 226 | + Key: key, |
| 227 | + CertPEM: certPEM, |
| 228 | + KeyPEM: keyPEM, |
| 229 | + }, nil |
| 230 | +} |
0 commit comments