When an order finalization response is not immediately valid, Client.CreateOrderCert depends on the ACME server returning a Location header. The header value is used as the URL for polling the updated order status. RFC 8555 does not require this header, so CreateOrderCert does not interoperate with ACME servers that omit it (e.g. Pebble v2.9.0+). This commit adds Client.CreateCertFromOrder, which accepts an *Order and uses its URI for polling instead of relying on the finalization response's Location header. It also updates existing callers and documentation to use the new method and updates the integration tests to Pebble v2.10.1 (the latest available version), which omits the finalization Location header. Fixes golang/go#77704 Change-Id: I0852f19c5d18d6debc9c9686fa2c14a2e39663c2 Reviewed-on: https://go-review.googlesource.com/c/crypto/+/817000 LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Cherry Mui <cherryyz@google.com> Reviewed-by: Roland Shoemaker <roland@golang.org>
434 lines
13 KiB
Go
434 lines
13 KiB
Go
// Copyright 2019 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
// The acmeprober program runs against an actual ACME CA implementation.
|
|
// It spins up an HTTP server to fulfill authorization challenges
|
|
// or execute a DNS script to provision a response to dns-01 challenge.
|
|
//
|
|
// For http-01 and tls-alpn-01 challenge types this requires the ACME CA
|
|
// to be able to reach the HTTP server.
|
|
//
|
|
// A usage example:
|
|
//
|
|
// go run prober.go \
|
|
// -d https://acme-staging-v02.api.letsencrypt.org/directory \
|
|
// -f order \
|
|
// -t http-01 \
|
|
// -a :8080 \
|
|
// -domain some.example.org
|
|
//
|
|
// The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080
|
|
// in order for the test to be able to fulfill http-01 challenge.
|
|
// To test tls-alpn-01 challenge, 443 port would need to be tunneled
|
|
// to 0.0.0.0:8080.
|
|
// When running with dns-01 challenge type, use -s argument instead of -a.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/acme"
|
|
)
|
|
|
|
var (
|
|
// ACME CA directory URL.
|
|
// Let's Encrypt v2 prod: https://acme-v02.api.letsencrypt.org/directory
|
|
// Let's Encrypt v2 staging: https://acme-staging-v02.api.letsencrypt.org/directory
|
|
// See the following for more CAs implementing ACME protocol:
|
|
// https://en.wikipedia.org/wiki/Automated_Certificate_Management_Environment#CAs_&_PKIs_that_offer_ACME_certificates
|
|
directory = flag.String("d", "", "ACME directory URL.")
|
|
reginfo = flag.String("r", "", "ACME account registration info.")
|
|
flow = flag.String("f", "", `Flow to run: "order" or "preauthz" (RFC8555).`)
|
|
chaltyp = flag.String("t", "", "Challenge type: tls-alpn-01, http-01 or dns-01.")
|
|
addr = flag.String("a", "", "Local server address for tls-alpn-01 and http-01.")
|
|
dnsscript = flag.String("s", "", "Script to run for provisioning dns-01 challenges.")
|
|
domain = flag.String("domain", "", "Space separate domain identifiers.")
|
|
ipaddr = flag.String("ip", "", "Space separate IP address identifiers.")
|
|
)
|
|
|
|
func main() {
|
|
flag.Usage = func() {
|
|
fmt.Fprintln(flag.CommandLine.Output(), `
|
|
The prober program runs against an actual ACME CA implementation.
|
|
It spins up an HTTP server to fulfill authorization challenges
|
|
or execute a DNS script to provision a response to dns-01 challenge.
|
|
|
|
For http-01 and tls-alpn-01 challenge types this requires the ACME CA
|
|
to be able to reach the HTTP server.
|
|
|
|
A usage example:
|
|
|
|
go run prober.go \
|
|
-d https://acme-staging-v02.api.letsencrypt.org/directory \
|
|
-f order \
|
|
-t http-01 \
|
|
-a :8080 \
|
|
-domain some.example.org
|
|
|
|
The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080
|
|
in order for the test to be able to fulfill http-01 challenge.
|
|
To test tls-alpn-01 challenge, 443 port would need to be tunneled
|
|
to 0.0.0.0:8080.
|
|
When running with dns-01 challenge type, use -s argument instead of -a.
|
|
`)
|
|
flag.PrintDefaults()
|
|
}
|
|
flag.Parse()
|
|
|
|
identifiers := acme.DomainIDs(strings.Fields(*domain)...)
|
|
identifiers = append(identifiers, acme.IPIDs(strings.Fields(*ipaddr)...)...)
|
|
if len(identifiers) == 0 {
|
|
log.Fatal("at least one domain or IP addr identifier is required")
|
|
}
|
|
|
|
// Duration of the whole run.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
|
|
// Create and register a new account.
|
|
akey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
cl := &acme.Client{Key: akey, DirectoryURL: *directory}
|
|
a := &acme.Account{Contact: strings.Fields(*reginfo)}
|
|
if _, err := cl.Register(ctx, a, acme.AcceptTOS); err != nil {
|
|
log.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
// Run the desired flow test.
|
|
p := &prober{
|
|
client: cl,
|
|
chalType: *chaltyp,
|
|
localAddr: *addr,
|
|
dnsScript: *dnsscript,
|
|
}
|
|
switch *flow {
|
|
case "order":
|
|
p.runOrder(ctx, identifiers)
|
|
case "preauthz":
|
|
p.runPreauthz(ctx, identifiers)
|
|
default:
|
|
log.Fatalf("unknown flow: %q", *flow)
|
|
}
|
|
if len(p.errors) > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
type prober struct {
|
|
client *acme.Client
|
|
chalType string
|
|
localAddr string
|
|
dnsScript string
|
|
|
|
errors []error
|
|
}
|
|
|
|
func (p *prober) errorf(format string, a ...interface{}) {
|
|
err := fmt.Errorf(format, a...)
|
|
log.Print(err)
|
|
p.errors = append(p.errors, err)
|
|
}
|
|
|
|
func (p *prober) runOrder(ctx context.Context, identifiers []acme.AuthzID) {
|
|
// Create a new order and pick a challenge.
|
|
// Note that Let's Encrypt will reply with 400 error:malformed
|
|
// "NotBefore and NotAfter are not supported" when providing a NotAfter
|
|
// value like WithOrderNotAfter(time.Now().Add(24 * time.Hour)).
|
|
o, err := p.client.AuthorizeOrder(ctx, identifiers)
|
|
if err != nil {
|
|
log.Fatalf("AuthorizeOrder: %v", err)
|
|
}
|
|
|
|
var zurls []string
|
|
for _, u := range o.AuthzURLs {
|
|
z, err := p.client.GetAuthorization(ctx, u)
|
|
if err != nil {
|
|
log.Fatalf("GetAuthorization(%q): %v", u, err)
|
|
}
|
|
log.Printf("%+v", z)
|
|
if z.Status != acme.StatusPending {
|
|
log.Printf("authz status is %q; skipping", z.Status)
|
|
continue
|
|
}
|
|
if err := p.fulfill(ctx, z); err != nil {
|
|
log.Fatalf("fulfill(%s): %v", z.URI, err)
|
|
}
|
|
zurls = append(zurls, z.URI)
|
|
log.Printf("authorized for %+v", z.Identifier)
|
|
}
|
|
|
|
log.Print("all challenges are done")
|
|
if _, err := p.client.WaitOrder(ctx, o.URI); err != nil {
|
|
log.Fatalf("WaitOrder(%q): %v", o.URI, err)
|
|
}
|
|
csr, certkey := newCSR(identifiers)
|
|
der, curl, err := p.client.CreateCertFromOrder(ctx, o, csr, true)
|
|
if err != nil {
|
|
log.Fatalf("CreateCertFromOrder: %v", err)
|
|
}
|
|
log.Printf("cert URL: %s", curl)
|
|
if err := checkCert(der, identifiers); err != nil {
|
|
p.errorf("invalid cert: %v", err)
|
|
}
|
|
|
|
// Deactivate all authorizations we satisfied earlier.
|
|
for _, v := range zurls {
|
|
if err := p.client.RevokeAuthorization(ctx, v); err != nil {
|
|
p.errorf("RevokAuthorization(%q): %v", v, err)
|
|
continue
|
|
}
|
|
}
|
|
// Deactivate the account. We don't need it for any further calls.
|
|
if err := p.client.DeactivateReg(ctx); err != nil {
|
|
p.errorf("DeactivateReg: %v", err)
|
|
}
|
|
// Try revoking the issued cert using its private key.
|
|
if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil {
|
|
p.errorf("RevokeCert: %v", err)
|
|
}
|
|
}
|
|
|
|
func (p *prober) runPreauthz(ctx context.Context, identifiers []acme.AuthzID) {
|
|
dir, err := p.client.Discover(ctx)
|
|
if err != nil {
|
|
log.Fatalf("Discover: %v", err)
|
|
}
|
|
if dir.AuthzURL == "" {
|
|
log.Fatal("CA does not support pre-authorization")
|
|
}
|
|
|
|
var zurls []string
|
|
for _, id := range identifiers {
|
|
z, err := authorize(ctx, p.client, id)
|
|
if err != nil {
|
|
log.Fatalf("AuthorizeID(%+v): %v", z, err)
|
|
}
|
|
if z.Status == acme.StatusValid {
|
|
log.Printf("authz %s is valid; skipping", z.URI)
|
|
continue
|
|
}
|
|
if err := p.fulfill(ctx, z); err != nil {
|
|
log.Fatalf("fulfill(%s): %v", z.URI, err)
|
|
}
|
|
zurls = append(zurls, z.URI)
|
|
log.Printf("authorized for %+v", id)
|
|
}
|
|
|
|
// We should be all set now.
|
|
// Expect all authorizations to be satisfied.
|
|
log.Print("all challenges are done")
|
|
o, err := p.client.AuthorizeOrder(ctx, identifiers)
|
|
if err != nil {
|
|
log.Fatalf("AuthorizeOrder: %v", err)
|
|
}
|
|
waitCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
|
defer cancel()
|
|
if _, err := p.client.WaitOrder(waitCtx, o.URI); err != nil {
|
|
log.Fatalf("WaitOrder(%q): %v", o.URI, err)
|
|
}
|
|
csr, certkey := newCSR(identifiers)
|
|
der, curl, err := p.client.CreateCertFromOrder(ctx, o, csr, true)
|
|
if err != nil {
|
|
log.Fatalf("CreateCertFromOrder: %v", err)
|
|
}
|
|
log.Printf("cert URL: %s", curl)
|
|
if err := checkCert(der, identifiers); err != nil {
|
|
p.errorf("invalid cert: %v", err)
|
|
}
|
|
|
|
// Deactivate all authorizations we satisfied earlier.
|
|
for _, v := range zurls {
|
|
if err := p.client.RevokeAuthorization(ctx, v); err != nil {
|
|
p.errorf("RevokeAuthorization(%q): %v", v, err)
|
|
continue
|
|
}
|
|
}
|
|
// Deactivate the account. We don't need it for any further calls.
|
|
if err := p.client.DeactivateReg(ctx); err != nil {
|
|
p.errorf("DeactivateReg: %v", err)
|
|
}
|
|
// Try revoking the issued cert using its private key.
|
|
if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil {
|
|
p.errorf("RevokeCert: %v", err)
|
|
}
|
|
}
|
|
|
|
func (p *prober) fulfill(ctx context.Context, z *acme.Authorization) error {
|
|
var chal *acme.Challenge
|
|
for i, c := range z.Challenges {
|
|
log.Printf("challenge %d: %+v", i, c)
|
|
if c.Type == p.chalType {
|
|
log.Printf("picked %s for authz %s", c.URI, z.URI)
|
|
chal = c
|
|
}
|
|
}
|
|
if chal == nil {
|
|
return fmt.Errorf("challenge type %q wasn't offered for authz %s", p.chalType, z.URI)
|
|
}
|
|
|
|
switch chal.Type {
|
|
case "tls-alpn-01":
|
|
return p.runTLSALPN01(ctx, z, chal)
|
|
case "http-01":
|
|
return p.runHTTP01(ctx, z, chal)
|
|
case "dns-01":
|
|
return p.runDNS01(ctx, z, chal)
|
|
default:
|
|
return fmt.Errorf("unknown challenge type %q", chal.Type)
|
|
}
|
|
}
|
|
|
|
func (p *prober) runTLSALPN01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
|
|
tokenCert, err := p.client.TLSALPN01ChallengeCert(chal.Token, z.Identifier.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("TLSALPN01ChallengeCert: %v", err)
|
|
}
|
|
s := &http.Server{
|
|
Addr: p.localAddr,
|
|
TLSConfig: &tls.Config{
|
|
NextProtos: []string{acme.ALPNProto},
|
|
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
log.Printf("hello: %+v", hello)
|
|
return &tokenCert, nil
|
|
},
|
|
},
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("%s %s", r.Method, r.URL)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}),
|
|
}
|
|
go s.ListenAndServeTLS("", "")
|
|
defer s.Close()
|
|
|
|
if _, err := p.client.Accept(ctx, chal); err != nil {
|
|
return fmt.Errorf("Accept(%q): %v", chal.URI, err)
|
|
}
|
|
_, zerr := p.client.WaitAuthorization(ctx, z.URI)
|
|
return zerr
|
|
}
|
|
|
|
func (p *prober) runHTTP01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
|
|
body, err := p.client.HTTP01ChallengeResponse(chal.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("HTTP01ChallengeResponse: %v", err)
|
|
}
|
|
s := &http.Server{
|
|
Addr: p.localAddr,
|
|
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
log.Printf("%s %s", r.Method, r.URL)
|
|
if r.URL.Path != p.client.HTTP01ChallengePath(chal.Token) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Write([]byte(body))
|
|
}),
|
|
}
|
|
go s.ListenAndServe()
|
|
defer s.Close()
|
|
|
|
if _, err := p.client.Accept(ctx, chal); err != nil {
|
|
return fmt.Errorf("Accept(%q): %v", chal.URI, err)
|
|
}
|
|
_, zerr := p.client.WaitAuthorization(ctx, z.URI)
|
|
return zerr
|
|
}
|
|
|
|
func (p *prober) runDNS01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
|
|
token, err := p.client.DNS01ChallengeRecord(chal.Token)
|
|
if err != nil {
|
|
return fmt.Errorf("DNS01ChallengeRecord: %v", err)
|
|
}
|
|
|
|
name := fmt.Sprintf("_acme-challenge.%s", z.Identifier.Value)
|
|
cmd := exec.CommandContext(ctx, p.dnsScript, name, token)
|
|
cmd.Stdin = os.Stdin
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("%s: %v", p.dnsScript, err)
|
|
}
|
|
|
|
if _, err := p.client.Accept(ctx, chal); err != nil {
|
|
return fmt.Errorf("Accept(%q): %v", chal.URI, err)
|
|
}
|
|
_, zerr := p.client.WaitAuthorization(ctx, z.URI)
|
|
return zerr
|
|
}
|
|
|
|
func authorize(ctx context.Context, client *acme.Client, id acme.AuthzID) (*acme.Authorization, error) {
|
|
if id.Type == "ip" {
|
|
return client.AuthorizeIP(ctx, id.Value)
|
|
}
|
|
return client.Authorize(ctx, id.Value)
|
|
}
|
|
|
|
func checkCert(derChain [][]byte, id []acme.AuthzID) error {
|
|
if len(derChain) == 0 {
|
|
return errors.New("cert chain is zero bytes")
|
|
}
|
|
for i, b := range derChain {
|
|
crt, err := x509.ParseCertificate(b)
|
|
if err != nil {
|
|
return fmt.Errorf("%d: ParseCertificate: %v", i, err)
|
|
}
|
|
log.Printf("%d: serial: 0x%s", i, crt.SerialNumber)
|
|
log.Printf("%d: subject: %s", i, crt.Subject)
|
|
log.Printf("%d: issuer: %s", i, crt.Issuer)
|
|
log.Printf("%d: expires in %.1f day(s)", i, time.Until(crt.NotAfter).Hours()/24)
|
|
if i > 0 { // not a leaf cert
|
|
continue
|
|
}
|
|
p := &pem.Block{Type: "CERTIFICATE", Bytes: b}
|
|
log.Printf("%d: leaf:\n%s", i, pem.EncodeToMemory(p))
|
|
for _, v := range id {
|
|
if err := crt.VerifyHostname(v.Value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newCSR(identifiers []acme.AuthzID) ([]byte, crypto.Signer) {
|
|
var csr x509.CertificateRequest
|
|
for _, id := range identifiers {
|
|
switch id.Type {
|
|
case "dns":
|
|
csr.DNSNames = append(csr.DNSNames, id.Value)
|
|
case "ip":
|
|
csr.IPAddresses = append(csr.IPAddresses, net.ParseIP(id.Value))
|
|
default:
|
|
panic(fmt.Sprintf("newCSR: unknown identifier type %q", id.Type))
|
|
}
|
|
}
|
|
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("newCSR: ecdsa.GenerateKey for a cert: %v", err))
|
|
}
|
|
b, err := x509.CreateCertificateRequest(rand.Reader, &csr, k)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("newCSR: x509.CreateCertificateRequest: %v", err))
|
|
}
|
|
return b, k
|
|
}
|