package certs import ( "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/tls" "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "net/http" "os" "regexp" "strings" "sync" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/registration" "jarl/internal/acme" "jarl/internal/auth" "jarl/internal/engine" "jarl/internal/model" ) // Account is the ACME account certificates are obtained with. Its key is // kept in Jarl's database (and so in backups), like TSIG secrets. type Account struct { Directory string `json:"directory"` Email string `json:"email,omitempty"` URI string `json:"uri,omitempty"` // the account at the CA RegisteredAt time.Time `json:"registered_at"` KeyPEM string `json:"key_pem,omitempty"` } const metaAccount = "acme.account" // account returns the stored account (nil when there is none). func (m *Manager) account(ctx context.Context) *Account { raw, err := m.store.GetMeta(ctx, metaAccount) if err != nil { return nil } var a Account if json.Unmarshal([]byte(raw), &a) != nil { return nil } return &a } type legoUser struct { email string reg *registration.Resource key crypto.PrivateKey } func (u *legoUser) GetEmail() string { return u.email } func (u *legoUser) GetRegistration() *registration.Resource { return u.reg } func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key } // ErrTermsNotAgreed means acme.account.agree_tos isn't set. var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set") func (m *Manager) httpClient() (*http.Client, error) { tr := http.DefaultTransport.(*http.Transport).Clone() tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} if m.cfg.CABundle != "" { pem, err := os.ReadFile(m.cfg.CABundle) if err != nil { return nil, fmt.Errorf("acme.ca_bundle: %w", err) } pool, err := x509.SystemCertPool() if err != nil || pool == nil { pool = x509.NewCertPool() } if !pool.AppendCertsFromPEM(pem) { return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle) } tr.TLSClientConfig.RootCAs = pool } return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil } // client returns a lego client for the account (registering it when // needed) that makes keys of keyType. func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) { dir := m.cfg.Directory hc, err := m.httpClient() if err != nil { return nil, err } a := m.account(ctx) user := &legoUser{email: m.cfg.Email} if a != nil && a.Directory == dir && a.KeyPEM != "" { if user.key, err = parseKey(a.KeyPEM); err != nil { return nil, fmt.Errorf("the stored ACME account key: %w", err) } if a.URI != "" { user.reg = ®istration.Resource{URI: a.URI} } } else { k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, err } user.key = k } cfg := lego.NewConfig(user) cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version cfg.Certificate.KeyType = legoKeyType(keyType) cfg.Certificate.Timeout = 90 * time.Second c, err := lego.NewClient(cfg) if err != nil { return nil, fmt.Errorf("ACME server %s: %w", dir, err) } if user.reg != nil && (a.Email == m.cfg.Email) { return c, nil } if user.reg != nil { // Only the email changed: update the account. if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil { user.reg = reg return c, m.saveAccount(ctx, user, dir) } } if !m.cfg.AgreeTOS { tos := c.GetToSURL() if tos == "" { tos = "on the CA's website" } return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos) } var reg *registration.Resource if m.cfg.EABKeyID != "" { reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC}) } else { if c.GetExternalAccountRequired() { return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir) } reg, err = c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) } if err != nil { return nil, fmt.Errorf("registering the ACME account: %w", err) } user.reg = reg m.log.Info("ACME account registered", "directory", dir, "account", reg.URI) return c, m.saveAccount(ctx, user, dir) } func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error { der, err := x509.MarshalPKCS8PrivateKey(u.key) if err != nil { return err } a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(), KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))} b, _ := json.Marshal(a) return m.store.SetMeta(ctx, metaAccount, string(b)) } func parseKey(s string) (crypto.PrivateKey, error) { b, _ := pem.Decode([]byte(s)) if b == nil { return nil, errors.New("not PEM") } return x509.ParsePKCS8PrivateKey(b.Bytes) } func legoKeyType(t string) certcrypto.KeyType { switch t { case "ec384": return certcrypto.EC384 case "rsa2048": return certcrypto.RSA2048 case "rsa3072": return certcrypto.RSA3072 case "rsa4096": return certcrypto.RSA4096 } return certcrypto.EC256 } // KeyTypes are the key types a certificate Jarl obtains can have. var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"} // provider answers DNS-01 challenges for one issue: in a zone Jarl serves, // or at Porkbun, as route decides for each name. type provider struct { m *Manager ctx context.Context cert *model.Certificate mu sync.Mutex cleanups map[string]func() error routes []Route } func (p *provider) Present(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) st, err := p.m.store.LoadState(p.ctx) if err != nil { return err } r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN) p.mu.Lock() p.routes = append(p.routes, r) p.mu.Unlock() if r.Error != "" { return errors.New(r.Error) } key := info.EffectiveFQDN + " " + info.Value var cleanup func() error switch r.Route { case model.ChallengeJarl: if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil { return err } cleanup = func() error { return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false) } case model.ChallengePorkbun: zone := strings.TrimSuffix(r.Zone, ".") id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value) if err != nil { return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err) } cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) } } p.mu.Lock() p.cleanups[key] = cleanup p.mu.Unlock() return nil } func (p *provider) CleanUp(domain, token, keyAuth string) error { info := dns01.GetChallengeInfo(domain, keyAuth) key := info.EffectiveFQDN + " " + info.Value p.mu.Lock() f := p.cleanups[key] delete(p.cleanups, key) p.mu.Unlock() if f == nil { return nil } if err := f(); err != nil { p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err) return err } return nil } // Timeout is how long lego waits for the record to reach every name // server of the zone: Porkbun's take a few minutes. func (p *provider) Timeout() (timeout, interval time.Duration) { if p.m.cfg.PropagationTimeout > 0 { return p.m.cfg.PropagationTimeout, 2 * time.Second } return 10 * time.Minute, 10 * time.Second } // publish writes (or removes) a challenge TXT record in the Jarl zone that // holds name, in every view that has it, and returns once named serves it. func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error { st, err := m.store.LoadState(ctx) if err != nil { return err } zones := acme.Zones(name, primaryZones(st), "") if len(zones) == 0 { return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, ".")) } var reqs []engine.Request for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) { reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data}) } desc := acme.Description(name, set) + " (certificate " + c.Name + ")" if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil { return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err) } return nil } // obtain gets a new certificate for c from the CA. old is the certificate // it replaces (nil for the first), so the CA can link them (ARI). func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) { m.legoMu.Lock() defer m.legoMu.Unlock() client, err := m.client(ctx, c.KeyType) if err != nil { return nil, nil, err } p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}} opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)} if m.cfg.SkipPropagationCheck { opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement()) } if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil { return nil, nil, err } req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true} if old != nil { if id, err := certificate.MakeARICertID(old); err == nil { req.ReplacesCertID = id } } res, err := client.Certificate.Obtain(req) if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil { // The CA refused the order itself, before any challenge: it may // not link the new certificate to the old one (another CA, one // already replaced, or its own bug). Ask for a plain new one. m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err)) req.ReplacesCertID = "" res, err = client.Certificate.Obtain(req) } if err != nil { return nil, p.routes, cleanLegoError(err) } return res, p.routes, nil } // renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when // it doesn't say. func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) { m.legoMu.Lock() defer m.legoMu.Unlock() client, err := m.client(ctx, c.KeyType) if err != nil { return nil, err } ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf}) if err != nil { if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") { return nil, nil } return nil, err } // A time within the CA's window (now if it has started); never later // than two thirds into the lifetime, in case the window is odd. at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour) if at == nil { return nil, nil } t := at.UTC().Truncate(time.Second) if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) { t = limit } return &t, nil } func twoThirds(nb, na time.Time) time.Time { return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second) } // cleanLegoError shortens lego's errors, which wrap each name's problem in // several prefixes and the whole ACME problem document: // // error: one or more domains had a problem: // [a.example.com] [a.example.com] acme: error presenting token: MSG // [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG // // becomes "a.example.com: MSG; b.example.com: the CA refused (unauthorized): MSG". func cleanLegoError(err error) error { msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1)) var out []string for _, line := range strings.Split(msg, "\n") { line = strings.TrimSpace(line) if line == "" { continue } domain := "" for strings.HasPrefix(line, "[") { end := strings.Index(line, "] ") if end < 0 { break } if domain == "" { domain = line[1:end] } line = line[end+2:] } for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} { line = strings.TrimPrefix(line, p) } if m := acmeProblemRe.FindStringSubmatch(line); m != nil { line = "the CA refused (" + m[2] + "): " + m[3] } if domain != "" { line = domain + ": " + line } out = append(out, line) } if len(out) == 0 { return err } return errors.New(strings.Join(out, "; ")) } // acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail". var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)