233
pkg/cert/acme.go
Normal file
233
pkg/cert/acme.go
Normal file
@@ -0,0 +1,233 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package cert
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/getprobo/probo/pkg/crypto/keys"
|
||||
cryptopem "github.com/getprobo/probo/pkg/crypto/pem"
|
||||
"golang.org/x/crypto/acme"
|
||||
)
|
||||
|
||||
type (
|
||||
Certificate struct {
|
||||
CertPEM []byte
|
||||
KeyPEM []byte
|
||||
ChainPEM []byte
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
ACMEService struct {
|
||||
client *acme.Client
|
||||
email string
|
||||
keyType keys.Type
|
||||
}
|
||||
|
||||
DNSChallenge struct {
|
||||
Domain string
|
||||
RecordName string
|
||||
RecordValue string
|
||||
Token string
|
||||
URL string
|
||||
OrderURL string
|
||||
}
|
||||
)
|
||||
|
||||
func NewACMEService(email string, keyType keys.Type, directoryURL string) (*ACMEService, error) {
|
||||
accountKey, err := keys.Generate(keyType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate account key: %w", err)
|
||||
}
|
||||
|
||||
client := &acme.Client{Key: accountKey, DirectoryURL: directoryURL}
|
||||
|
||||
service := &ACMEService{
|
||||
client: client,
|
||||
email: email,
|
||||
keyType: keyType,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := service.registerAccount(ctx); err != nil {
|
||||
return nil, fmt.Errorf("cannot register ACME account: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) registerAccount(ctx context.Context) error {
|
||||
account := &acme.Account{Contact: []string{"mailto:" + s.email}}
|
||||
|
||||
if _, err := s.client.Register(ctx, account, acme.AcceptTOS); err != nil {
|
||||
if err != acme.ErrAccountAlreadyExists {
|
||||
return fmt.Errorf("cannot register account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) GetDNSChallenge(ctx context.Context, domain string) (*DNSChallenge, error) {
|
||||
order, err := s.client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create order: %w", err)
|
||||
}
|
||||
|
||||
var challenge *acme.Challenge
|
||||
for _, auth := range order.AuthzURLs {
|
||||
authz, err := s.client.GetAuthorization(ctx, auth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get authorization: %w", err)
|
||||
}
|
||||
|
||||
for _, ch := range authz.Challenges {
|
||||
if ch.Type == "dns-01" {
|
||||
challenge = ch
|
||||
break
|
||||
}
|
||||
|
||||
if ch.Type == "http-01" {
|
||||
return nil, fmt.Errorf("http-01 challenges are not supported")
|
||||
}
|
||||
}
|
||||
|
||||
if challenge != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if challenge == nil {
|
||||
return nil, fmt.Errorf("no DNS-01 challenge found")
|
||||
}
|
||||
|
||||
recordValue, err := s.client.DNS01ChallengeRecord(challenge.Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get DNS record value: %w", err)
|
||||
}
|
||||
|
||||
return &DNSChallenge{
|
||||
Domain: domain,
|
||||
RecordName: fmt.Sprintf("_acme-challenge.%s", domain),
|
||||
RecordValue: recordValue,
|
||||
Token: challenge.Token,
|
||||
URL: challenge.URI,
|
||||
OrderURL: order.URI,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) CompleteDNSChallenge(
|
||||
ctx context.Context,
|
||||
challenge0 *DNSChallenge,
|
||||
) (*Certificate, error) {
|
||||
|
||||
challenge1 := &acme.Challenge{
|
||||
URI: challenge0.URL,
|
||||
Token: challenge0.Token,
|
||||
}
|
||||
|
||||
if _, err := s.client.Accept(ctx, challenge1); err != nil {
|
||||
return nil, fmt.Errorf("cannot accept challenge: %w", err)
|
||||
}
|
||||
|
||||
order, err := s.client.WaitOrder(ctx, challenge0.OrderURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot wait for order: %w", err)
|
||||
}
|
||||
|
||||
certKey, err := keys.Generate(s.keyType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot generate certificate key: %w", err)
|
||||
}
|
||||
|
||||
csr, err := createCSR(challenge0.Domain, certKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create CSR: %w", err)
|
||||
}
|
||||
|
||||
der, _, err := s.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(der[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse certificate: %w", err)
|
||||
}
|
||||
|
||||
certPEM := cryptopem.EncodeCertificate(der[0])
|
||||
keyPEM, err := cryptopem.EncodePrivateKey(certKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot encode key: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for i := 1; i < len(der); i++ {
|
||||
buf.Write(cryptopem.EncodeCertificate(der[i]))
|
||||
}
|
||||
|
||||
return &Certificate{
|
||||
CertPEM: certPEM,
|
||||
KeyPEM: keyPEM,
|
||||
ChainPEM: buf.Bytes(),
|
||||
ExpiresAt: cert.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) InitiateRenewal(
|
||||
ctx context.Context,
|
||||
domain string,
|
||||
) (*DNSChallenge, error) {
|
||||
challenge, err := s.GetDNSChallenge(ctx, domain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot get DNS challenge for renewal: %w", err)
|
||||
}
|
||||
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) CompleteRenewal(
|
||||
ctx context.Context,
|
||||
challenge *DNSChallenge,
|
||||
) (*Certificate, error) {
|
||||
cert, err := s.CompleteDNSChallenge(ctx, challenge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot complete renewal challenge: %w", err)
|
||||
}
|
||||
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
func (s *ACMEService) CheckRenewalNeeded(expiresAt time.Time, threshold time.Duration) bool {
|
||||
return time.Until(expiresAt) <= threshold
|
||||
}
|
||||
|
||||
func createCSR(domain string, key crypto.Signer) ([]byte, error) {
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: domain,
|
||||
},
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
|
||||
return x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
}
|
||||
Reference in New Issue
Block a user