Newer
Older
EnvoyControlPlane / internal / pkg / cert / letsencrypt / issuer.go
package letsencrypt

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/rand"
	"fmt"
	"math/big"

	"github.com/go-acme/lego/v4/certcrypto"
	"github.com/go-acme/lego/v4/certificate"
	"github.com/go-acme/lego/v4/lego"
	"github.com/go-acme/lego/v4/providers/http/webroot"
	"github.com/go-acme/lego/v4/registration"

	internalcertapi "envoy-control-plane/internal/pkg/cert/api"
)

// LEOptions is a simple struct to satisfy the necessary interface for the lego ACME client.
type LEOptions struct {
	Email        string
	Registration *registration.Resource
	key          crypto.PrivateKey
}

func (u *LEOptions) GetEmail() string {
	return u.Email
}

func (u *LEOptions) GetRegistration() *registration.Resource {
	return u.Registration
}

func (u *LEOptions) GetPrivateKey() crypto.PrivateKey {
	return u.key
}

// LetsEncryptIssuer implements the CertIssuer interface for Let's Encrypt.
type LetsEncryptIssuer struct {
	UseStaging bool
}

// GetName returns the name of the issuer.
func (l *LetsEncryptIssuer) GetName() string {
	if l.UseStaging {
		return "LetsEncrypt (Staging)"
	}
	return "LetsEncrypt (Production)"
}

// ------------------------------------------------------------------------------------------------

// IssueCertificate implements the core certificate issuance logic using go-acme/lego.
func (l *LetsEncryptIssuer) IssueCertificate(domain, webrootPath, email string) (*internalcertapi.Certificate, error) {
	// 1. Setup ACME Account Key and User
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		return nil, fmt.Errorf("failed to generate private key: %w", err)
	}

	acmeUser := &LEOptions{
		Email: email,
		key:   privateKey,
	}

	client, err := createClient(acmeUser, webrootPath, l.UseStaging)
	if err != nil {
		return nil, fmt.Errorf("failed to create ACME client: %w", err)
	}

	// 4. Register the ACME account
	reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
	if err != nil {
		return nil, fmt.Errorf("failed to register ACME account: %w", err)
	}
	acmeUser.Registration = reg

	// 5. Issue the certificate
	request := certificate.ObtainRequest{
		Domains: []string{domain},
		Bundle:  true, // Include full chain
	}

	certResources, err := client.Certificate.Obtain(request)
	if err != nil {
		return nil, fmt.Errorf("failed to obtain certificate: %w", err)
	}

	// 6. Map the results to the generic Certificate struct
	return &internalcertapi.Certificate{
		Domain:     domain,
		CertPEM:    certResources.Certificate,
		KeyPEM:     certResources.PrivateKey,
		FullChain:  certResources.Certificate,
		AccountKey: privateKey.D.Bytes(),
		// FIX: Persist the ACME Account URL (KID) for future renewal JWS signing.
		// ASSUMPTION: internalcertapi.Certificate has an 'AccountURL' field.
		AccountURL: reg.URI,
	}, nil
}

// ------------------------------------------------------------------------------------------------

// createClient is a helper function to avoid code duplication in Issue and Renew.
func createClient(acmeUser *LEOptions, webrootPath string, useStaging bool) (*lego.Client, error) {
	config := lego.NewConfig(acmeUser)
	if useStaging {
		config.CADirURL = lego.LEDirectoryStaging
	} else {
		config.CADirURL = lego.LEDirectoryProduction
	}
	config.Certificate.KeyType = certcrypto.EC256

	client, err := lego.NewClient(config)
	if err != nil {
		return nil, fmt.Errorf("failed to create ACME client: %w", err)
	}

	httpProvider, err := webroot.NewHTTPProvider(webrootPath)
	if err != nil {
		return nil, fmt.Errorf("failed to create HTTP-01 provider: %w", err)
	}
	if err := client.Challenge.SetHTTP01Provider(httpProvider); err != nil {
		return nil, fmt.Errorf("failed to set HTTP-01 provider: %w", err)
	}

	return client, nil
}

// ------------------------------------------------------------------------------------------------

// loadACMEKeyFromDValue reconstructs the ecdsa.PrivateKey from its raw D-value bytes.
func loadACMEKeyFromDValue(dValue []byte) (*ecdsa.PrivateKey, error) {
	if len(dValue) != 32 {
		return nil, fmt.Errorf("invalid D-value length: expected 32 bytes for P256, got %d", len(dValue))
	}

	key := new(ecdsa.PrivateKey)
	key.PublicKey.Curve = elliptic.P256()
	key.D = new(big.Int).SetBytes(dValue)

	// Calculate public key components X and Y from D
	key.PublicKey.X, key.PublicKey.Y = key.PublicKey.Curve.ScalarBaseMult(key.D.Bytes())

	return key, nil
}