feat(pki): implement CA adapter with root, intermediate, and leaf issuance
- commit
- b939cf11a6b11e0063dde2e33e0d5e91a63fc64a
- author
- brice · · 2026-03-29 16:34 UTC
- committer
- brice · · 2026-03-29 16:40 UTC
- parent
- 2242016
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +524 -0
A internal/adapters/out/pki/ca.go +402 -0
| @@ -0,0 +1,402 @@ | |||
| 1 | + | // Package pki implements the certificate authority adapter for internal PKI operations. | |
| 2 | + | package pki | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "crypto" | |
| 6 | + | "crypto/ecdsa" | |
| 7 | + | "crypto/elliptic" | |
| 8 | + | "crypto/rand" | |
| 9 | + | "crypto/sha256" | |
| 10 | + | "crypto/tls" | |
| 11 | + | "crypto/x509" | |
| 12 | + | "crypto/x509/pkix" | |
| 13 | + | "encoding/pem" | |
| 14 | + | "fmt" | |
| 15 | + | "math/big" | |
| 16 | + | "os" | |
| 17 | + | "path/filepath" | |
| 18 | + | "strings" | |
| 19 | + | "sync" | |
| 20 | + | "time" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/zerowrap" | |
| 23 | + | ||
| 24 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | |
| 28 | + | var _ out.CertificateAuthority = (*CA)(nil) | |
| 29 | + | ||
| 30 | + | const ( | |
| 31 | + | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | |
| 32 | + | intermediateLifetime = 7 * 24 * time.Hour // 7 days | |
| 33 | + | leafLifetime = 12 * time.Hour // 12 hours | |
| 34 | + | ||
| 35 | + | pkiDir = "pki" | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | // CA implements the CertificateAuthority boundary interface. | |
| 39 | + | type CA struct { | |
| 40 | + | dataDir string | |
| 41 | + | log zerowrap.Logger | |
| 42 | + | aiaURL string // optional: URL for AIA extension in leaf certs | |
| 43 | + | ||
| 44 | + | rootCert *x509.Certificate | |
| 45 | + | rootKey crypto.Signer | |
| 46 | + | rootPEM []byte | |
| 47 | + | rootDER []byte | |
| 48 | + | ||
| 49 | + | mu sync.RWMutex | |
| 50 | + | interCert *x509.Certificate | |
| 51 | + | interKey crypto.Signer | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // SetAIAURL sets the Authority Information Access URL embedded in leaf certs. | |
| 55 | + | // Format: http://{gordon_domain}[:{port}]/ca.crt | |
| 56 | + | func (ca *CA) SetAIAURL(url string) { ca.aiaURL = url } | |
| 57 | + | ||
| 58 | + | // NewCA loads or generates the root and intermediate CA certificates. | |
| 59 | + | func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) { | |
| 60 | + | ca := &CA{ | |
| 61 | + | dataDir: dataDir, | |
| 62 | + | log: log, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | dir := filepath.Join(dataDir, pkiDir) | |
| 66 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |
| 67 | + | return nil, fmt.Errorf("create pki dir: %w", err) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | if err := ca.loadOrGenerateRoot(); err != nil { | |
| 71 | + | return nil, fmt.Errorf("root CA: %w", err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if err := ca.loadOrGenerateIntermediate(); err != nil { | |
| 75 | + | return nil, fmt.Errorf("intermediate CA: %w", err) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return ca, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // RootCertificate returns the root CA certificate in PEM format. | |
| 82 | + | func (ca *CA) RootCertificate() []byte { return ca.rootPEM } | |
| 83 | + | ||
| 84 | + | // RootCertificateDER returns the root CA certificate in DER format. | |
| 85 | + | func (ca *CA) RootCertificateDER() []byte { return ca.rootDER } | |
| 86 | + | ||
| 87 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |
| 88 | + | // formatted as colon-separated hex. | |
| 89 | + | func (ca *CA) RootFingerprint() string { | |
| 90 | + | sum := sha256.Sum256(ca.rootCert.Raw) | |
| 91 | + | parts := make([]string, len(sum)) | |
| 92 | + | for i, b := range sum { | |
| 93 | + | parts[i] = fmt.Sprintf("%02X", b) | |
| 94 | + | } | |
| 95 | + | return strings.Join(parts, ":") | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // RootCommonName returns the CN of the root CA certificate. | |
| 99 | + | func (ca *CA) RootCommonName() string { | |
| 100 | + | return ca.rootCert.Subject.CommonName | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |
| 104 | + | func (ca *CA) IntermediateExpiresAt() time.Time { | |
| 105 | + | ca.mu.RLock() | |
| 106 | + | defer ca.mu.RUnlock() | |
| 107 | + | return ca.interCert.NotAfter | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |
| 111 | + | // signed by the root CA. Existing leaf certs remain valid. | |
| 112 | + | func (ca *CA) RenewIntermediate() error { | |
| 113 | + | cert, key, err := ca.generateIntermediate() | |
| 114 | + | if err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |
| 118 | + | return err | |
| 119 | + | } | |
| 120 | + | ca.mu.Lock() | |
| 121 | + | ca.interCert = cert | |
| 122 | + | ca.interKey = key | |
| 123 | + | ca.mu.Unlock() | |
| 124 | + | ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed") | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (ca *CA) loadOrGenerateRoot() error { | |
| 129 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt") | |
| 130 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key") | |
| 131 | + | ||
| 132 | + | certPEM, certErr := os.ReadFile(certPath) | |
| 133 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |
| 134 | + | ||
| 135 | + | if certErr == nil && keyErr == nil { | |
| 136 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |
| 137 | + | if err == nil { | |
| 138 | + | ca.rootCert = cert | |
| 139 | + | ca.rootKey = key | |
| 140 | + | ca.rootPEM = certPEM | |
| 141 | + | ca.rootDER = cert.Raw | |
| 142 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA") | |
| 143 | + | return nil | |
| 144 | + | } | |
| 145 | + | ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 149 | + | if err != nil { | |
| 150 | + | return fmt.Errorf("generate root key: %w", err) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | serial, err := randomSerial() | |
| 154 | + | if err != nil { | |
| 155 | + | return err | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | now := time.Now() | |
| 159 | + | template := &x509.Certificate{ | |
| 160 | + | SerialNumber: serial, | |
| 161 | + | Subject: pkix.Name{ | |
| 162 | + | CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()), | |
| 163 | + | Organization: []string{"Gordon"}, | |
| 164 | + | }, | |
| 165 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 166 | + | NotAfter: now.Add(rootLifetime), | |
| 167 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |
| 168 | + | BasicConstraintsValid: true, | |
| 169 | + | IsCA: true, | |
| 170 | + | MaxPathLen: 1, | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) | |
| 174 | + | if err != nil { | |
| 175 | + | return fmt.Errorf("create root cert: %w", err) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | cert, err := x509.ParseCertificate(certDER) | |
| 179 | + | if err != nil { | |
| 180 | + | return fmt.Errorf("parse root cert: %w", err) | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 184 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 185 | + | if err != nil { | |
| 186 | + | return fmt.Errorf("marshal root key: %w", err) | |
| 187 | + | } | |
| 188 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 189 | + | ||
| 190 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |
| 191 | + | return err | |
| 192 | + | } | |
| 193 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |
| 194 | + | return err | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | ca.rootCert = cert | |
| 198 | + | ca.rootKey = key | |
| 199 | + | ca.rootPEM = certPEM | |
| 200 | + | ca.rootDER = certDER | |
| 201 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA") | |
| 202 | + | return nil | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func (ca *CA) loadOrGenerateIntermediate() error { | |
| 206 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |
| 207 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |
| 208 | + | ||
| 209 | + | certPEM, certErr := os.ReadFile(certPath) | |
| 210 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |
| 211 | + | ||
| 212 | + | if certErr == nil && keyErr == nil { | |
| 213 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |
| 214 | + | if err == nil && time.Now().Before(cert.NotAfter) { | |
| 215 | + | ca.interCert = cert | |
| 216 | + | ca.interKey = key | |
| 217 | + | ca.log.Info(). | |
| 218 | + | Str("cn", cert.Subject.CommonName). | |
| 219 | + | Time("expires", cert.NotAfter). | |
| 220 | + | Msg("loaded existing intermediate CA") | |
| 221 | + | return nil | |
| 222 | + | } | |
| 223 | + | if err != nil { | |
| 224 | + | ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating") | |
| 225 | + | } else { | |
| 226 | + | ca.log.Warn().Msg("intermediate CA expired, regenerating") | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | cert, key, err := ca.generateIntermediate() | |
| 231 | + | if err != nil { | |
| 232 | + | return err | |
| 233 | + | } | |
| 234 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | ca.interCert = cert | |
| 238 | + | ca.interKey = key | |
| 239 | + | return nil | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) { | |
| 243 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 244 | + | if err != nil { | |
| 245 | + | return nil, nil, fmt.Errorf("generate intermediate key: %w", err) | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | serial, err := randomSerial() | |
| 249 | + | if err != nil { | |
| 250 | + | return nil, nil, err | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | now := time.Now() | |
| 254 | + | template := &x509.Certificate{ | |
| 255 | + | SerialNumber: serial, | |
| 256 | + | Subject: pkix.Name{ | |
| 257 | + | CommonName: "Gordon Internal CA - ECC Intermediate", | |
| 258 | + | Organization: []string{"Gordon"}, | |
| 259 | + | }, | |
| 260 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 261 | + | NotAfter: now.Add(intermediateLifetime), | |
| 262 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |
| 263 | + | BasicConstraintsValid: true, | |
| 264 | + | IsCA: true, | |
| 265 | + | MaxPathLen: 0, | |
| 266 | + | MaxPathLenZero: true, | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey) | |
| 270 | + | if err != nil { | |
| 271 | + | return nil, nil, fmt.Errorf("create intermediate cert: %w", err) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | cert, err := x509.ParseCertificate(certDER) | |
| 275 | + | if err != nil { | |
| 276 | + | return nil, nil, fmt.Errorf("parse intermediate cert: %w", err) | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | ca.log.Info(). | |
| 280 | + | Str("cn", cert.Subject.CommonName). | |
| 281 | + | Time("expires", cert.NotAfter). | |
| 282 | + | Msg("generated new intermediate CA") | |
| 283 | + | return cert, key, nil | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error { | |
| 287 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |
| 288 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |
| 289 | + | ||
| 290 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) | |
| 291 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 292 | + | if err != nil { | |
| 293 | + | return fmt.Errorf("marshal intermediate key: %w", err) | |
| 294 | + | } | |
| 295 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 296 | + | ||
| 297 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |
| 298 | + | return err | |
| 299 | + | } | |
| 300 | + | return writeSecure(keyPath, keyPEM, 0600) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // IssueCertificate generates a leaf cert for the given domain, | |
| 304 | + | // signed by the current intermediate CA. | |
| 305 | + | func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |
| 306 | + | ca.mu.RLock() | |
| 307 | + | interCert := ca.interCert | |
| 308 | + | interKey := ca.interKey | |
| 309 | + | ca.mu.RUnlock() | |
| 310 | + | ||
| 311 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 312 | + | if err != nil { | |
| 313 | + | return nil, fmt.Errorf("generate leaf key: %w", err) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | serial, err := randomSerial() | |
| 317 | + | if err != nil { | |
| 318 | + | return nil, err | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | now := time.Now() | |
| 322 | + | template := &x509.Certificate{ | |
| 323 | + | SerialNumber: serial, | |
| 324 | + | Subject: pkix.Name{ | |
| 325 | + | CommonName: domain, | |
| 326 | + | }, | |
| 327 | + | DNSNames: []string{domain}, | |
| 328 | + | NotBefore: now.Add(-5 * time.Minute), | |
| 329 | + | NotAfter: now.Add(leafLifetime), | |
| 330 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 331 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // AIA extension: URL where clients can fetch the CA cert. | |
| 335 | + | if ca.aiaURL != "" { | |
| 336 | + | template.IssuingCertificateURL = []string{ca.aiaURL} | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey) | |
| 340 | + | if err != nil { | |
| 341 | + | return nil, fmt.Errorf("create leaf cert: %w", err) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 345 | + | interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw}) | |
| 346 | + | chainPEM := append(leafPEM, interPEM...) | |
| 347 | + | ||
| 348 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 349 | + | if err != nil { | |
| 350 | + | return nil, fmt.Errorf("marshal leaf key: %w", err) | |
| 351 | + | } | |
| 352 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 353 | + | ||
| 354 | + | tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM) | |
| 355 | + | if err != nil { | |
| 356 | + | return nil, fmt.Errorf("create TLS cert: %w", err) | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | return &tlsCert, nil | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |
| 363 | + | func (ca *CA) LeafLifetime() time.Duration { | |
| 364 | + | return leafLifetime | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func randomSerial() (*big.Int, error) { | |
| 368 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, fmt.Errorf("generate serial: %w", err) | |
| 371 | + | } | |
| 372 | + | return serial, nil | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) { | |
| 376 | + | block, _ := pem.Decode(certPEM) | |
| 377 | + | if block == nil { | |
| 378 | + | return nil, nil, fmt.Errorf("failed to decode certificate PEM") | |
| 379 | + | } | |
| 380 | + | cert, err := x509.ParseCertificate(block.Bytes) | |
| 381 | + | if err != nil { | |
| 382 | + | return nil, nil, fmt.Errorf("parse certificate: %w", err) | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | keyBlock, _ := pem.Decode(keyPEM) | |
| 386 | + | if keyBlock == nil { | |
| 387 | + | return nil, nil, fmt.Errorf("failed to decode key PEM") | |
| 388 | + | } | |
| 389 | + | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) | |
| 390 | + | if err != nil { | |
| 391 | + | return nil, nil, fmt.Errorf("parse key: %w", err) | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | return cert, key, nil | |
| 395 | + | } | |
| 396 | + | ||
| 397 | + | func writeSecure(path string, data []byte, perm os.FileMode) error { | |
| 398 | + | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { | |
| 399 | + | return fmt.Errorf("create dir for %s: %w", path, err) | |
| 400 | + | } | |
| 401 | + | return os.WriteFile(path, data, perm) | |
| 402 | + | } | |
| @@ -0,0 +1,402 @@ | |||||
| 1 | + | // Package pki implements the certificate authority adapter for internal PKI operations. | |||
| 2 | + | package pki | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "crypto" | |||
| 6 | + | "crypto/ecdsa" | |||
| 7 | + | "crypto/elliptic" | |||
| 8 | + | "crypto/rand" | |||
| 9 | + | "crypto/sha256" | |||
| 10 | + | "crypto/tls" | |||
| 11 | + | "crypto/x509" | |||
| 12 | + | "crypto/x509/pkix" | |||
| 13 | + | "encoding/pem" | |||
| 14 | + | "fmt" | |||
| 15 | + | "math/big" | |||
| 16 | + | "os" | |||
| 17 | + | "path/filepath" | |||
| 18 | + | "strings" | |||
| 19 | + | "sync" | |||
| 20 | + | "time" | |||
| 21 | + | ||||
| 22 | + | "github.com/bnema/zerowrap" | |||
| 23 | + | ||||
| 24 | + | out "github.com/bnema/gordon/internal/boundaries/out" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | |||
| 28 | + | var _ out.CertificateAuthority = (*CA)(nil) | |||
| 29 | + | ||||
| 30 | + | const ( | |||
| 31 | + | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | |||
| 32 | + | intermediateLifetime = 7 * 24 * time.Hour // 7 days | |||
| 33 | + | leafLifetime = 12 * time.Hour // 12 hours | |||
| 34 | + | ||||
| 35 | + | pkiDir = "pki" | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | // CA implements the CertificateAuthority boundary interface. | |||
| 39 | + | type CA struct { | |||
| 40 | + | dataDir string | |||
| 41 | + | log zerowrap.Logger | |||
| 42 | + | aiaURL string // optional: URL for AIA extension in leaf certs | |||
| 43 | + | ||||
| 44 | + | rootCert *x509.Certificate | |||
| 45 | + | rootKey crypto.Signer | |||
| 46 | + | rootPEM []byte | |||
| 47 | + | rootDER []byte | |||
| 48 | + | ||||
| 49 | + | mu sync.RWMutex | |||
| 50 | + | interCert *x509.Certificate | |||
| 51 | + | interKey crypto.Signer | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // SetAIAURL sets the Authority Information Access URL embedded in leaf certs. | |||
| 55 | + | // Format: http://{gordon_domain}[:{port}]/ca.crt | |||
| 56 | + | func (ca *CA) SetAIAURL(url string) { ca.aiaURL = url } | |||
| 57 | + | ||||
| 58 | + | // NewCA loads or generates the root and intermediate CA certificates. | |||
| 59 | + | func NewCA(dataDir string, log zerowrap.Logger) (*CA, error) { | |||
| 60 | + | ca := &CA{ | |||
| 61 | + | dataDir: dataDir, | |||
| 62 | + | log: log, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | dir := filepath.Join(dataDir, pkiDir) | |||
| 66 | + | if err := os.MkdirAll(dir, 0700); err != nil { | |||
| 67 | + | return nil, fmt.Errorf("create pki dir: %w", err) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | if err := ca.loadOrGenerateRoot(); err != nil { | |||
| 71 | + | return nil, fmt.Errorf("root CA: %w", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if err := ca.loadOrGenerateIntermediate(); err != nil { | |||
| 75 | + | return nil, fmt.Errorf("intermediate CA: %w", err) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return ca, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // RootCertificate returns the root CA certificate in PEM format. | |||
| 82 | + | func (ca *CA) RootCertificate() []byte { return ca.rootPEM } | |||
| 83 | + | ||||
| 84 | + | // RootCertificateDER returns the root CA certificate in DER format. | |||
| 85 | + | func (ca *CA) RootCertificateDER() []byte { return ca.rootDER } | |||
| 86 | + | ||||
| 87 | + | // RootFingerprint returns the SHA-256 fingerprint of the root CA cert | |||
| 88 | + | // formatted as colon-separated hex. | |||
| 89 | + | func (ca *CA) RootFingerprint() string { | |||
| 90 | + | sum := sha256.Sum256(ca.rootCert.Raw) | |||
| 91 | + | parts := make([]string, len(sum)) | |||
| 92 | + | for i, b := range sum { | |||
| 93 | + | parts[i] = fmt.Sprintf("%02X", b) | |||
| 94 | + | } | |||
| 95 | + | return strings.Join(parts, ":") | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // RootCommonName returns the CN of the root CA certificate. | |||
| 99 | + | func (ca *CA) RootCommonName() string { | |||
| 100 | + | return ca.rootCert.Subject.CommonName | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // IntermediateExpiresAt returns the intermediate CA certificate expiry. | |||
| 104 | + | func (ca *CA) IntermediateExpiresAt() time.Time { | |||
| 105 | + | ca.mu.RLock() | |||
| 106 | + | defer ca.mu.RUnlock() | |||
| 107 | + | return ca.interCert.NotAfter | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // RenewIntermediate regenerates the intermediate CA certificate, | |||
| 111 | + | // signed by the root CA. Existing leaf certs remain valid. | |||
| 112 | + | func (ca *CA) RenewIntermediate() error { | |||
| 113 | + | cert, key, err := ca.generateIntermediate() | |||
| 114 | + | if err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | ca.mu.Lock() | |||
| 121 | + | ca.interCert = cert | |||
| 122 | + | ca.interKey = key | |||
| 123 | + | ca.mu.Unlock() | |||
| 124 | + | ca.log.Info().Time("expires", cert.NotAfter).Msg("intermediate CA renewed") | |||
| 125 | + | return nil | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func (ca *CA) loadOrGenerateRoot() error { | |||
| 129 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "root.crt") | |||
| 130 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "root.key") | |||
| 131 | + | ||||
| 132 | + | certPEM, certErr := os.ReadFile(certPath) | |||
| 133 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |||
| 134 | + | ||||
| 135 | + | if certErr == nil && keyErr == nil { | |||
| 136 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |||
| 137 | + | if err == nil { | |||
| 138 | + | ca.rootCert = cert | |||
| 139 | + | ca.rootKey = key | |||
| 140 | + | ca.rootPEM = certPEM | |||
| 141 | + | ca.rootDER = cert.Raw | |||
| 142 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("loaded existing root CA") | |||
| 143 | + | return nil | |||
| 144 | + | } | |||
| 145 | + | ca.log.Warn().Err(err).Msg("failed to parse existing root CA, regenerating") | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return fmt.Errorf("generate root key: %w", err) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | serial, err := randomSerial() | |||
| 154 | + | if err != nil { | |||
| 155 | + | return err | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | now := time.Now() | |||
| 159 | + | template := &x509.Certificate{ | |||
| 160 | + | SerialNumber: serial, | |||
| 161 | + | Subject: pkix.Name{ | |||
| 162 | + | CommonName: fmt.Sprintf("Gordon Internal CA - %d ECC Root", now.Year()), | |||
| 163 | + | Organization: []string{"Gordon"}, | |||
| 164 | + | }, | |||
| 165 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 166 | + | NotAfter: now.Add(rootLifetime), | |||
| 167 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |||
| 168 | + | BasicConstraintsValid: true, | |||
| 169 | + | IsCA: true, | |||
| 170 | + | MaxPathLen: 1, | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) | |||
| 174 | + | if err != nil { | |||
| 175 | + | return fmt.Errorf("create root cert: %w", err) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | cert, err := x509.ParseCertificate(certDER) | |||
| 179 | + | if err != nil { | |||
| 180 | + | return fmt.Errorf("parse root cert: %w", err) | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 184 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 185 | + | if err != nil { | |||
| 186 | + | return fmt.Errorf("marshal root key: %w", err) | |||
| 187 | + | } | |||
| 188 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 189 | + | ||||
| 190 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |||
| 191 | + | return err | |||
| 192 | + | } | |||
| 193 | + | if err := writeSecure(keyPath, keyPEM, 0600); err != nil { | |||
| 194 | + | return err | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | ca.rootCert = cert | |||
| 198 | + | ca.rootKey = key | |||
| 199 | + | ca.rootPEM = certPEM | |||
| 200 | + | ca.rootDER = certDER | |||
| 201 | + | ca.log.Info().Str("cn", cert.Subject.CommonName).Msg("generated new root CA") | |||
| 202 | + | return nil | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func (ca *CA) loadOrGenerateIntermediate() error { | |||
| 206 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |||
| 207 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |||
| 208 | + | ||||
| 209 | + | certPEM, certErr := os.ReadFile(certPath) | |||
| 210 | + | keyPEM, keyErr := os.ReadFile(keyPath) | |||
| 211 | + | ||||
| 212 | + | if certErr == nil && keyErr == nil { | |||
| 213 | + | cert, key, err := parseCertAndKey(certPEM, keyPEM) | |||
| 214 | + | if err == nil && time.Now().Before(cert.NotAfter) { | |||
| 215 | + | ca.interCert = cert | |||
| 216 | + | ca.interKey = key | |||
| 217 | + | ca.log.Info(). | |||
| 218 | + | Str("cn", cert.Subject.CommonName). | |||
| 219 | + | Time("expires", cert.NotAfter). | |||
| 220 | + | Msg("loaded existing intermediate CA") | |||
| 221 | + | return nil | |||
| 222 | + | } | |||
| 223 | + | if err != nil { | |||
| 224 | + | ca.log.Warn().Err(err).Msg("failed to parse intermediate CA, regenerating") | |||
| 225 | + | } else { | |||
| 226 | + | ca.log.Warn().Msg("intermediate CA expired, regenerating") | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | cert, key, err := ca.generateIntermediate() | |||
| 231 | + | if err != nil { | |||
| 232 | + | return err | |||
| 233 | + | } | |||
| 234 | + | if err := ca.storeIntermediate(cert, key); err != nil { | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | ca.interCert = cert | |||
| 238 | + | ca.interKey = key | |||
| 239 | + | return nil | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | func (ca *CA) generateIntermediate() (*x509.Certificate, *ecdsa.PrivateKey, error) { | |||
| 243 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 244 | + | if err != nil { | |||
| 245 | + | return nil, nil, fmt.Errorf("generate intermediate key: %w", err) | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | serial, err := randomSerial() | |||
| 249 | + | if err != nil { | |||
| 250 | + | return nil, nil, err | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | now := time.Now() | |||
| 254 | + | template := &x509.Certificate{ | |||
| 255 | + | SerialNumber: serial, | |||
| 256 | + | Subject: pkix.Name{ | |||
| 257 | + | CommonName: "Gordon Internal CA - ECC Intermediate", | |||
| 258 | + | Organization: []string{"Gordon"}, | |||
| 259 | + | }, | |||
| 260 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 261 | + | NotAfter: now.Add(intermediateLifetime), | |||
| 262 | + | KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, | |||
| 263 | + | BasicConstraintsValid: true, | |||
| 264 | + | IsCA: true, | |||
| 265 | + | MaxPathLen: 0, | |||
| 266 | + | MaxPathLenZero: true, | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, ca.rootCert, key.Public(), ca.rootKey) | |||
| 270 | + | if err != nil { | |||
| 271 | + | return nil, nil, fmt.Errorf("create intermediate cert: %w", err) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | cert, err := x509.ParseCertificate(certDER) | |||
| 275 | + | if err != nil { | |||
| 276 | + | return nil, nil, fmt.Errorf("parse intermediate cert: %w", err) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | ca.log.Info(). | |||
| 280 | + | Str("cn", cert.Subject.CommonName). | |||
| 281 | + | Time("expires", cert.NotAfter). | |||
| 282 | + | Msg("generated new intermediate CA") | |||
| 283 | + | return cert, key, nil | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (ca *CA) storeIntermediate(cert *x509.Certificate, key *ecdsa.PrivateKey) error { | |||
| 287 | + | certPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.crt") | |||
| 288 | + | keyPath := filepath.Join(ca.dataDir, pkiDir, "intermediate.key") | |||
| 289 | + | ||||
| 290 | + | certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) | |||
| 291 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 292 | + | if err != nil { | |||
| 293 | + | return fmt.Errorf("marshal intermediate key: %w", err) | |||
| 294 | + | } | |||
| 295 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 296 | + | ||||
| 297 | + | if err := writeSecure(certPath, certPEM, 0644); err != nil { | |||
| 298 | + | return err | |||
| 299 | + | } | |||
| 300 | + | return writeSecure(keyPath, keyPEM, 0600) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // IssueCertificate generates a leaf cert for the given domain, | |||
| 304 | + | // signed by the current intermediate CA. | |||
| 305 | + | func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |||
| 306 | + | ca.mu.RLock() | |||
| 307 | + | interCert := ca.interCert | |||
| 308 | + | interKey := ca.interKey | |||
| 309 | + | ca.mu.RUnlock() | |||
| 310 | + | ||||
| 311 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |||
| 312 | + | if err != nil { | |||
| 313 | + | return nil, fmt.Errorf("generate leaf key: %w", err) | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | serial, err := randomSerial() | |||
| 317 | + | if err != nil { | |||
| 318 | + | return nil, err | |||
| 319 | + | } | |||
| 320 | + | ||||
| 321 | + | now := time.Now() | |||
| 322 | + | template := &x509.Certificate{ | |||
| 323 | + | SerialNumber: serial, | |||
| 324 | + | Subject: pkix.Name{ | |||
| 325 | + | CommonName: domain, | |||
| 326 | + | }, | |||
| 327 | + | DNSNames: []string{domain}, | |||
| 328 | + | NotBefore: now.Add(-5 * time.Minute), | |||
| 329 | + | NotAfter: now.Add(leafLifetime), | |||
| 330 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |||
| 331 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // AIA extension: URL where clients can fetch the CA cert. | |||
| 335 | + | if ca.aiaURL != "" { | |||
| 336 | + | template.IssuingCertificateURL = []string{ca.aiaURL} | |||
| 337 | + | } | |||
| 338 | + | ||||
| 339 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, interCert, key.Public(), interKey) | |||
| 340 | + | if err != nil { | |||
| 341 | + | return nil, fmt.Errorf("create leaf cert: %w", err) | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |||
| 345 | + | interPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: interCert.Raw}) | |||
| 346 | + | chainPEM := append(leafPEM, interPEM...) | |||
| 347 | + | ||||
| 348 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |||
| 349 | + | if err != nil { | |||
| 350 | + | return nil, fmt.Errorf("marshal leaf key: %w", err) | |||
| 351 | + | } | |||
| 352 | + | keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |||
| 353 | + | ||||
| 354 | + | tlsCert, err := tls.X509KeyPair(chainPEM, keyPEM) | |||
| 355 | + | if err != nil { | |||
| 356 | + | return nil, fmt.Errorf("create TLS cert: %w", err) | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | return &tlsCert, nil | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | // LeafLifetime returns the configured leaf certificate lifetime. | |||
| 363 | + | func (ca *CA) LeafLifetime() time.Duration { | |||
| 364 | + | return leafLifetime | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func randomSerial() (*big.Int, error) { | |||
| 368 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |||
| 369 | + | if err != nil { | |||
| 370 | + | return nil, fmt.Errorf("generate serial: %w", err) | |||
| 371 | + | } | |||
| 372 | + | return serial, nil | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func parseCertAndKey(certPEM, keyPEM []byte) (*x509.Certificate, crypto.Signer, error) { | |||
| 376 | + | block, _ := pem.Decode(certPEM) | |||
| 377 | + | if block == nil { | |||
| 378 | + | return nil, nil, fmt.Errorf("failed to decode certificate PEM") | |||
| 379 | + | } | |||
| 380 | + | cert, err := x509.ParseCertificate(block.Bytes) | |||
| 381 | + | if err != nil { | |||
| 382 | + | return nil, nil, fmt.Errorf("parse certificate: %w", err) | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | keyBlock, _ := pem.Decode(keyPEM) | |||
| 386 | + | if keyBlock == nil { | |||
| 387 | + | return nil, nil, fmt.Errorf("failed to decode key PEM") | |||
| 388 | + | } | |||
| 389 | + | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) | |||
| 390 | + | if err != nil { | |||
| 391 | + | return nil, nil, fmt.Errorf("parse key: %w", err) | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | return cert, key, nil | |||
| 395 | + | } | |||
| 396 | + | ||||
| 397 | + | func writeSecure(path string, data []byte, perm os.FileMode) error { | |||
| 398 | + | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { | |||
| 399 | + | return fmt.Errorf("create dir for %s: %w", path, err) | |||
| 400 | + | } | |||
| 401 | + | return os.WriteFile(path, data, perm) | |||
| 402 | + | } | |||
A internal/adapters/out/pki/ca_test.go +122 -0
| @@ -0,0 +1,122 @@ | |||
| 1 | + | package pki_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/x509" | |
| 5 | + | "encoding/pem" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func testLogger() zerowrap.Logger { | |
| 18 | + | return zerowrap.Default() | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) { | |
| 22 | + | dir := t.TempDir() | |
| 23 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 24 | + | require.NoError(t, err) | |
| 25 | + | ||
| 26 | + | // Root cert exists on disk | |
| 27 | + | rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt")) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | block, _ := pem.Decode(rootPEM) | |
| 30 | + | require.NotNil(t, block) | |
| 31 | + | rootCert, err := x509.ParseCertificate(block.Bytes) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | assert.True(t, rootCert.IsCA) | |
| 34 | + | assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed | |
| 35 | + | ||
| 36 | + | // Intermediate cert exists on disk | |
| 37 | + | interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt")) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | block, _ = pem.Decode(interPEM) | |
| 40 | + | require.NotNil(t, block) | |
| 41 | + | interCert, err := x509.ParseCertificate(block.Bytes) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | assert.True(t, interCert.IsCA) | |
| 44 | + | assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root | |
| 45 | + | ||
| 46 | + | // Interface methods work | |
| 47 | + | assert.NotEmpty(t, ca.RootCertificate()) | |
| 48 | + | assert.NotEmpty(t, ca.RootCertificateDER()) | |
| 49 | + | assert.NotEmpty(t, ca.RootFingerprint()) | |
| 50 | + | assert.NotEmpty(t, ca.RootCommonName()) | |
| 51 | + | assert.False(t, ca.IntermediateExpiresAt().IsZero()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestNewCA_LoadsExistingOnRestart(t *testing.T) { | |
| 55 | + | dir := t.TempDir() | |
| 56 | + | ||
| 57 | + | // First boot: generate | |
| 58 | + | ca1, err := pki.NewCA(dir, testLogger()) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | fp1 := ca1.RootFingerprint() | |
| 61 | + | ||
| 62 | + | // Second boot: load | |
| 63 | + | ca2, err := pki.NewCA(dir, testLogger()) | |
| 64 | + | require.NoError(t, err) | |
| 65 | + | fp2 := ca2.RootFingerprint() | |
| 66 | + | ||
| 67 | + | assert.Equal(t, fp1, fp2, "root CA should be the same across restarts") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestCA_IssueCertificate(t *testing.T) { | |
| 71 | + | dir := t.TempDir() | |
| 72 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 73 | + | require.NoError(t, err) | |
| 74 | + | ||
| 75 | + | cert, err := ca.IssueCertificate("test.example.com") | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | require.NotNil(t, cert) | |
| 78 | + | ||
| 79 | + | // Parse the leaf cert | |
| 80 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |
| 81 | + | require.NoError(t, err) | |
| 82 | + | assert.Equal(t, "test.example.com", leaf.Subject.CommonName) | |
| 83 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |
| 84 | + | assert.False(t, leaf.IsCA) | |
| 85 | + | ||
| 86 | + | // Verify chain: leaf -> intermediate -> root | |
| 87 | + | rootPool := x509.NewCertPool() | |
| 88 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |
| 89 | + | interPool := x509.NewCertPool() | |
| 90 | + | inter, err := x509.ParseCertificate(cert.Certificate[1]) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | interPool.AddCert(inter) | |
| 93 | + | ||
| 94 | + | _, err = leaf.Verify(x509.VerifyOptions{ | |
| 95 | + | Roots: rootPool, | |
| 96 | + | Intermediates: interPool, | |
| 97 | + | }) | |
| 98 | + | assert.NoError(t, err, "leaf cert should verify against root CA") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestCA_RenewIntermediate(t *testing.T) { | |
| 102 | + | dir := t.TempDir() | |
| 103 | + | ca, err := pki.NewCA(dir, testLogger()) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | ||
| 106 | + | oldExpiry := ca.IntermediateExpiresAt() | |
| 107 | + | ||
| 108 | + | // x509 timestamps have second precision, so we must cross | |
| 109 | + | // a second boundary to observe a different NotAfter. | |
| 110 | + | time.Sleep(1100 * time.Millisecond) | |
| 111 | + | ||
| 112 | + | err = ca.RenewIntermediate() | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | ||
| 115 | + | newExpiry := ca.IntermediateExpiresAt() | |
| 116 | + | assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry") | |
| 117 | + | ||
| 118 | + | // Leaf certs should still work after renewal | |
| 119 | + | cert, err := ca.IssueCertificate("after-renewal.example.com") | |
| 120 | + | require.NoError(t, err) | |
| 121 | + | require.NotNil(t, cert) | |
| 122 | + | } | |
| @@ -0,0 +1,122 @@ | |||||
| 1 | + | package pki_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/x509" | |||
| 5 | + | "encoding/pem" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/out/pki" | |||
| 12 | + | "github.com/bnema/zerowrap" | |||
| 13 | + | "github.com/stretchr/testify/assert" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func testLogger() zerowrap.Logger { | |||
| 18 | + | return zerowrap.Default() | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestNewCA_GeneratesRootAndIntermediate(t *testing.T) { | |||
| 22 | + | dir := t.TempDir() | |||
| 23 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 24 | + | require.NoError(t, err) | |||
| 25 | + | ||||
| 26 | + | // Root cert exists on disk | |||
| 27 | + | rootPEM, err := os.ReadFile(filepath.Join(dir, "pki", "root.crt")) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | block, _ := pem.Decode(rootPEM) | |||
| 30 | + | require.NotNil(t, block) | |||
| 31 | + | rootCert, err := x509.ParseCertificate(block.Bytes) | |||
| 32 | + | require.NoError(t, err) | |||
| 33 | + | assert.True(t, rootCert.IsCA) | |||
| 34 | + | assert.Equal(t, rootCert.Issuer.CommonName, rootCert.Subject.CommonName) // self-signed | |||
| 35 | + | ||||
| 36 | + | // Intermediate cert exists on disk | |||
| 37 | + | interPEM, err := os.ReadFile(filepath.Join(dir, "pki", "intermediate.crt")) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | block, _ = pem.Decode(interPEM) | |||
| 40 | + | require.NotNil(t, block) | |||
| 41 | + | interCert, err := x509.ParseCertificate(block.Bytes) | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | assert.True(t, interCert.IsCA) | |||
| 44 | + | assert.NotEqual(t, interCert.Issuer.CommonName, interCert.Subject.CommonName) // signed by root | |||
| 45 | + | ||||
| 46 | + | // Interface methods work | |||
| 47 | + | assert.NotEmpty(t, ca.RootCertificate()) | |||
| 48 | + | assert.NotEmpty(t, ca.RootCertificateDER()) | |||
| 49 | + | assert.NotEmpty(t, ca.RootFingerprint()) | |||
| 50 | + | assert.NotEmpty(t, ca.RootCommonName()) | |||
| 51 | + | assert.False(t, ca.IntermediateExpiresAt().IsZero()) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestNewCA_LoadsExistingOnRestart(t *testing.T) { | |||
| 55 | + | dir := t.TempDir() | |||
| 56 | + | ||||
| 57 | + | // First boot: generate | |||
| 58 | + | ca1, err := pki.NewCA(dir, testLogger()) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | fp1 := ca1.RootFingerprint() | |||
| 61 | + | ||||
| 62 | + | // Second boot: load | |||
| 63 | + | ca2, err := pki.NewCA(dir, testLogger()) | |||
| 64 | + | require.NoError(t, err) | |||
| 65 | + | fp2 := ca2.RootFingerprint() | |||
| 66 | + | ||||
| 67 | + | assert.Equal(t, fp1, fp2, "root CA should be the same across restarts") | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestCA_IssueCertificate(t *testing.T) { | |||
| 71 | + | dir := t.TempDir() | |||
| 72 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 73 | + | require.NoError(t, err) | |||
| 74 | + | ||||
| 75 | + | cert, err := ca.IssueCertificate("test.example.com") | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | require.NotNil(t, cert) | |||
| 78 | + | ||||
| 79 | + | // Parse the leaf cert | |||
| 80 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |||
| 81 | + | require.NoError(t, err) | |||
| 82 | + | assert.Equal(t, "test.example.com", leaf.Subject.CommonName) | |||
| 83 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |||
| 84 | + | assert.False(t, leaf.IsCA) | |||
| 85 | + | ||||
| 86 | + | // Verify chain: leaf -> intermediate -> root | |||
| 87 | + | rootPool := x509.NewCertPool() | |||
| 88 | + | rootPool.AppendCertsFromPEM(ca.RootCertificate()) | |||
| 89 | + | interPool := x509.NewCertPool() | |||
| 90 | + | inter, err := x509.ParseCertificate(cert.Certificate[1]) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | interPool.AddCert(inter) | |||
| 93 | + | ||||
| 94 | + | _, err = leaf.Verify(x509.VerifyOptions{ | |||
| 95 | + | Roots: rootPool, | |||
| 96 | + | Intermediates: interPool, | |||
| 97 | + | }) | |||
| 98 | + | assert.NoError(t, err, "leaf cert should verify against root CA") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestCA_RenewIntermediate(t *testing.T) { | |||
| 102 | + | dir := t.TempDir() | |||
| 103 | + | ca, err := pki.NewCA(dir, testLogger()) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | ||||
| 106 | + | oldExpiry := ca.IntermediateExpiresAt() | |||
| 107 | + | ||||
| 108 | + | // x509 timestamps have second precision, so we must cross | |||
| 109 | + | // a second boundary to observe a different NotAfter. | |||
| 110 | + | time.Sleep(1100 * time.Millisecond) | |||
| 111 | + | ||||
| 112 | + | err = ca.RenewIntermediate() | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | ||||
| 115 | + | newExpiry := ca.IntermediateExpiresAt() | |||
| 116 | + | assert.True(t, newExpiry.After(oldExpiry), "renewed intermediate should have later expiry") | |||
| 117 | + | ||||
| 118 | + | // Leaf certs should still work after renewal | |||
| 119 | + | cert, err := ca.IssueCertificate("after-renewal.example.com") | |||
| 120 | + | require.NoError(t, err) | |||
| 121 | + | require.NotNil(t, cert) | |||
| 122 | + | } | |||