// Package mtls provides the mTLS/CA security foundation for the Pangolin control plane. // It implements a lightweight self-signed CA, CSR signing, one-time bootstrap tokens, // CRL revocation, and gRPC identity extraction interceptors. package mtls import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "math/big" "os" "path/filepath" "time" ) // CAConfig specifies where to persist the CA key and certificate. // Both paths must be writable on first run; subsequent runs load from disk. type CAConfig struct { // KeyPath is the file path for the CA private key (PEM-encoded ECDSA P-256). // The key never leaves the control-plane disk. KeyPath string // CertPath is the file path for the CA certificate (PEM-encoded X.509). CertPath string } // CA is a lightweight self-signed certificate authority. // It signs node client certificates and provides the CA cert for agent validation. type CA struct { key *ecdsa.PrivateKey cert *x509.Certificate certPEM []byte } // NewCA loads an existing CA from disk or generates a new ECDSA P-256 CA. // Generated key and certificate are persisted to the paths in cfg. func NewCA(cfg CAConfig) (*CA, error) { keyPEM, errKey := os.ReadFile(cfg.KeyPath) certPEM, errCert := os.ReadFile(cfg.CertPath) if errKey == nil && errCert == nil { key, err := parseECPrivateKey(keyPEM) if err != nil { return nil, fmt.Errorf("mtls: parse CA key: %w", err) } cert, err := parseCertificate(certPEM) if err != nil { return nil, fmt.Errorf("mtls: parse CA cert: %w", err) } return &CA{key: key, cert: cert, certPEM: certPEM}, nil } // Generate a new CA key pair and self-signed certificate. key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, fmt.Errorf("mtls: generate CA key: %w", err) } serial, err := randomSerial() if err != nil { return nil, err } now := time.Now() template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ Organization: []string{"Pangolin"}, CommonName: "Pangolin Node CA", }, NotBefore: now.Add(-time.Minute), // slight back-date for clock skew NotAfter: now.Add(10 * 365 * 24 * time.Hour), IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, BasicConstraintsValid: true, } certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) if err != nil { return nil, fmt.Errorf("mtls: self-sign CA: %w", err) } cert, err := x509.ParseCertificate(certDER) if err != nil { return nil, fmt.Errorf("mtls: parse self-signed CA: %w", err) } keyDER, err := x509.MarshalECPrivateKey(key) if err != nil { return nil, fmt.Errorf("mtls: marshal CA key: %w", err) } keyPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) certPEMBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) // Persist to disk; create parent directories as needed. if err := os.MkdirAll(filepath.Dir(cfg.KeyPath), 0o700); err != nil { return nil, fmt.Errorf("mtls: mkdir for CA key: %w", err) } if err := os.MkdirAll(filepath.Dir(cfg.CertPath), 0o755); err != nil { return nil, fmt.Errorf("mtls: mkdir for CA cert: %w", err) } // Key: owner-only read (0600) if err := os.WriteFile(cfg.KeyPath, keyPEMBytes, 0o600); err != nil { return nil, fmt.Errorf("mtls: write CA key: %w", err) } if err := os.WriteFile(cfg.CertPath, certPEMBytes, 0o644); err != nil { return nil, fmt.Errorf("mtls: write CA cert: %w", err) } return &CA{key: key, cert: cert, certPEM: certPEMBytes}, nil } // SignCSR signs a PEM-encoded CSR and issues a client certificate. // The certificate's Subject CN is overridden to nodeUUID regardless of what // the CSR requests. Validity is 90 days; EKU = ClientAuth only. func (ca *CA) SignCSR(csrPEM []byte, nodeUUID string) ([]byte, error) { block, _ := pem.Decode(csrPEM) if block == nil { return nil, fmt.Errorf("mtls: invalid CSR PEM") } csr, err := x509.ParseCertificateRequest(block.Bytes) if err != nil { return nil, fmt.Errorf("mtls: parse CSR: %w", err) } if err := csr.CheckSignature(); err != nil { return nil, fmt.Errorf("mtls: CSR signature invalid: %w", err) } serial, err := randomSerial() if err != nil { return nil, err } now := time.Now() template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{ CommonName: nodeUUID, }, NotBefore: now.Add(-time.Minute), NotAfter: now.Add(90 * 24 * time.Hour), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, } certDER, err := x509.CreateCertificate(rand.Reader, template, ca.cert, csr.PublicKey, ca.key) if err != nil { return nil, fmt.Errorf("mtls: sign CSR: %w", err) } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), nil } // CAPEM returns the CA certificate in PEM format. // This is sent to agents during Enroll so they can pin the server's CA. func (ca *CA) CAPEM() []byte { return ca.certPEM } // CACert returns the parsed CA certificate for building x509.CertPool entries. func (ca *CA) CACert() *x509.Certificate { return ca.cert } // ─── helpers ───────────────────────────────────────────────────────────────── func randomSerial() (*big.Int, error) { serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) if err != nil { return nil, fmt.Errorf("mtls: generate serial: %w", err) } return serial, nil } func parseECPrivateKey(pemData []byte) (*ecdsa.PrivateKey, error) { block, _ := pem.Decode(pemData) if block == nil { return nil, fmt.Errorf("no PEM block found") } return x509.ParseECPrivateKey(block.Bytes) } func parseCertificate(pemData []byte) (*x509.Certificate, error) { block, _ := pem.Decode(pemData) if block == nil { return nil, fmt.Errorf("no PEM block found") } return x509.ParseCertificate(block.Bytes) }